variable 0.1.4

Type-safe feature flag code generation from .var files
use std::io::Write;
use std::path::PathBuf;
use std::process;
use std::time::{SystemTime, UNIX_EPOCH};

use clap::{Parser, Subcommand};
use serde_json::json;

#[derive(Parser)]
#[command(name = "variable")]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    Generate {
        #[arg(long)]
        out: PathBuf,
        file: PathBuf,
    },
    Encode {
        #[arg(long, default_value_t = 0)]
        schema_revision: u64,
        #[arg(long, default_value_t = 0)]
        manifest_revision: u64,
        #[arg(long)]
        generated_at_unix_ms: Option<u64>,
        #[arg(long)]
        source: Option<String>,
        file: PathBuf,
    },
    Decode {
        #[arg(long)]
        pretty: bool,
        #[arg(long)]
        fail_on_error: bool,
        /// Optional .var schema file for resolving struct type names in decoded output
        #[arg(long)]
        schema: Option<PathBuf>,
        file: PathBuf,
    },
}

fn main() {
    let cli = Cli::parse();

    match cli.command {
        Commands::Generate { out, file } => {
            if let Err(e) = generate(&file, &out) {
                eprintln!("{}", e);
                process::exit(1);
            }
        }
        Commands::Encode {
            schema_revision,
            manifest_revision,
            generated_at_unix_ms,
            source,
            file,
        } => {
            let generated_at_unix_ms = match generated_at_unix_ms {
                Some(value) => value,
                None => match current_unix_ms() {
                    Ok(value) => value,
                    Err(e) => {
                        eprintln!("{}", e);
                        process::exit(1);
                    }
                },
            };

            let metadata = variable_wire::SnapshotMetadata {
                schema_revision,
                manifest_revision,
                generated_at_unix_ms,
                source,
            };

            if let Err(e) = encode(&file, metadata) {
                eprintln!("{}", e);
                process::exit(1);
            }
        }
        Commands::Decode {
            pretty,
            fail_on_error,
            schema,
            file,
        } => {
            if let Err(e) = decode(&file, pretty, fail_on_error, schema.as_ref()) {
                eprintln!("{}", e);
                process::exit(1);
            }
        }
    }
}

fn parse_var_file(file: &PathBuf) -> Result<variable_core::ast::VarFile, String> {
    let source = std::fs::read_to_string(file)
        .map_err(|e| format!("error reading {}: {}", file.display(), e))?;

    variable_core::parse_and_validate(&source).map_err(|e| match e {
        variable_core::Error::Lex(e) => {
            format!(
                "Lex error at line {}, column {}: {}",
                e.span.line, e.span.column, e.message
            )
        }
        variable_core::Error::Parse(e) => {
            format!(
                "Parse error at line {}, column {}: {}",
                e.span.line, e.span.column, e.message
            )
        }
        variable_core::Error::Validation(errors) => {
            let msgs: Vec<String> = errors.iter().map(|e| format!("{}", e)).collect();
            msgs.join("\n")
        }
    })
}

fn generate(file: &PathBuf, out_dir: &PathBuf) -> Result<(), String> {
    let var_file = parse_var_file(file)?;

    let ts_output = variable_codegen::generate_typescript(&var_file);

    std::fs::create_dir_all(out_dir)
        .map_err(|e| format!("error creating directory {}: {}", out_dir.display(), e))?;

    let stem = file
        .file_stem()
        .ok_or_else(|| format!("cannot determine file name from {}", file.display()))?;
    let out_file = out_dir.join(format!("{}.generated.ts", stem.to_string_lossy()));

    std::fs::write(&out_file, ts_output)
        .map_err(|e| format!("error writing {}: {}", out_file.display(), e))?;

    println!("Generated {}", out_file.display());
    Ok(())
}

fn encode(file: &PathBuf, metadata: variable_wire::SnapshotMetadata) -> Result<(), String> {
    let var_file = parse_var_file(file)?;
    let bytes = variable_wire::encode_var_file_defaults(&var_file, metadata)
        .map_err(|e| format!("encode error: {}", e))?;

    let mut stdout = std::io::stdout().lock();
    stdout
        .write_all(&bytes)
        .map_err(|e| format!("error writing binary output: {}", e))?;
    stdout
        .flush()
        .map_err(|e| format!("error flushing binary output: {}", e))?;
    Ok(())
}

fn current_unix_ms() -> Result<u64, String> {
    let duration = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map_err(|e| format!("error getting current system time: {}", e))?;
    Ok(duration.as_millis() as u64)
}

fn decode(
    file: &PathBuf,
    pretty: bool,
    fail_on_error: bool,
    schema: Option<&PathBuf>,
) -> Result<(), String> {
    let bytes =
        std::fs::read(file).map_err(|e| format!("error reading {}: {}", file.display(), e))?;

    // Optionally parse a .var schema for struct type name resolution
    let var_file = match schema {
        Some(schema_path) => Some(parse_var_file(schema_path)?),
        None => None,
    };

    let report = variable_wire::decode_snapshot(&bytes);

    let has_error = report
        .diagnostics
        .iter()
        .any(|diagnostic| diagnostic.severity == variable_wire::DiagnosticSeverity::Error);

    let json_output = decode_report_to_json(&report, var_file.as_ref());
    let rendered = if pretty {
        serde_json::to_string_pretty(&json_output)
            .map_err(|e| format!("error serializing decode output: {}", e))?
    } else {
        serde_json::to_string(&json_output)
            .map_err(|e| format!("error serializing decode output: {}", e))?
    };

    println!("{}", rendered);

    if fail_on_error && has_error {
        return Err("decode reported error diagnostics; see JSON output".to_string());
    }

    Ok(())
}

fn decode_report_to_json(
    report: &variable_wire::DecodeReport,
    _schema: Option<&variable_core::ast::VarFile>,
) -> serde_json::Value {
    let snapshot_json = report.snapshot.as_ref().map(|snapshot| {
        json!({
            "metadata": {
                "schema_revision": snapshot.metadata.schema_revision,
                "manifest_revision": snapshot.metadata.manifest_revision,
                "generated_at_unix_ms": snapshot.metadata.generated_at_unix_ms,
                "source": snapshot.metadata.source,
            },
            "features": snapshot.features.iter().map(|feature| {
                json!({
                    "feature_id": feature.feature_id,
                    "variables": feature.variables.iter().map(|variable| {
                        json!({
                            "variable_id": variable.variable_id,
                            "value": value_to_json(&variable.value),
                        })
                    }).collect::<Vec<_>>(),
                })
            }).collect::<Vec<_>>(),
        })
    });

    let diagnostics_json = report
        .diagnostics
        .iter()
        .map(|diagnostic| {
            json!({
                "kind": diagnostic_kind_str(diagnostic.kind),
                "severity": diagnostic_severity_str(diagnostic.severity),
                "message": diagnostic.message,
            })
        })
        .collect::<Vec<_>>();

    json!({
        "snapshot": snapshot_json,
        "diagnostics": diagnostics_json,
    })
}

fn value_to_json(value: &variable_core::ast::Value) -> serde_json::Value {
    match value {
        variable_core::ast::Value::Boolean(v) => json!({
            "type": "boolean",
            "value": v,
        }),
        variable_core::ast::Value::Integer(v) => json!({
            "type": "integer",
            "value": v,
        }),
        variable_core::ast::Value::Float(v) => json!({
            "type": "float",
            "value": v,
        }),
        variable_core::ast::Value::String(v) => json!({
            "type": "string",
            "value": v,
        }),
        variable_core::ast::Value::Struct {
            struct_name,
            fields,
        } => {
            let fields_json: serde_json::Map<std::string::String, serde_json::Value> = fields
                .iter()
                .map(|(k, v)| (k.clone(), value_to_json(v)))
                .collect();
            json!({
                "type": "struct",
                "struct_name": struct_name,
                "fields": fields_json,
            })
        }
    }
}

fn diagnostic_kind_str(kind: variable_wire::DiagnosticKind) -> &'static str {
    match kind {
        variable_wire::DiagnosticKind::UnsupportedVersion => "unsupported_version",
        variable_wire::DiagnosticKind::MalformedEnvelope => "malformed_envelope",
        variable_wire::DiagnosticKind::TruncatedSection => "truncated_section",
        variable_wire::DiagnosticKind::LimitExceeded => "limit_exceeded",
        variable_wire::DiagnosticKind::UnknownSectionType => "unknown_section_type",
        variable_wire::DiagnosticKind::DuplicateFeatureId => "duplicate_feature_id",
        variable_wire::DiagnosticKind::DuplicateVariableId => "duplicate_variable_id",
        variable_wire::DiagnosticKind::UnknownValueType => "unknown_value_type",
        variable_wire::DiagnosticKind::InvalidBooleanEncoding => "invalid_boolean_encoding",
        variable_wire::DiagnosticKind::InvalidNumberEncoding => "invalid_number_encoding",
        variable_wire::DiagnosticKind::InvalidUtf8String => "invalid_utf8_string",
    }
}

fn diagnostic_severity_str(severity: variable_wire::DiagnosticSeverity) -> &'static str {
    match severity {
        variable_wire::DiagnosticSeverity::Info => "info",
        variable_wire::DiagnosticSeverity::Warning => "warning",
        variable_wire::DiagnosticSeverity::Error => "error",
    }
}