bookforge-cli 1.5.0

CLI-first EPUB translation engine with deterministic structure rebuild and review loop.
use std::{fs, path::PathBuf};

use anyhow::{Context, Result};
use bookforge_core::{
    GlossaryScopeKind,
    style::{
        DoNotFields, RegisterFields, StyleSheet, VoiceFields, merge_style_sheets,
        render_style_block, style_fingerprint,
    },
};
use bookforge_store::{JobStore, NewStyleSheet};
use clap::{Args, Subcommand};
use serde::{Deserialize, Serialize};

#[derive(Debug, Args)]
pub struct StyleArgs {
    #[command(subcommand)]
    command: StyleCommand,
}

#[derive(Debug, Subcommand)]
enum StyleCommand {
    List(ListArgs),
    Import(ImportArgs),
    Export(ExportArgs),
    Clear(ClearArgs),
    Show(ShowArgs),
}

#[derive(Debug, Args)]
struct ListArgs {
    #[arg(long)]
    language: Option<String>,

    #[arg(long, value_enum)]
    scope: Option<GlossaryScopeKind>,

    #[arg(long)]
    scope_id: Option<String>,
}

#[derive(Debug, Args)]
struct ImportArgs {
    file: PathBuf,
}

#[derive(Debug, Args)]
struct ExportArgs {
    file: PathBuf,

    #[arg(long, value_enum)]
    scope: GlossaryScopeKind,

    #[arg(long)]
    scope_id: Option<String>,

    #[arg(long)]
    language: String,
}

#[derive(Debug, Args)]
struct ClearArgs {
    #[arg(long, value_enum)]
    scope: GlossaryScopeKind,

    #[arg(long)]
    scope_id: Option<String>,
}

#[derive(Debug, Args)]
struct ShowArgs {
    #[arg(long)]
    language: String,

    #[arg(long)]
    book_id: Option<String>,

    #[arg(long)]
    series_id: Option<String>,
}

#[derive(Debug, Deserialize, Serialize)]
struct StyleToml {
    meta: StyleTomlMeta,
    #[serde(default)]
    register: RegisterFields,
    #[serde(default)]
    voice: VoiceFields,
    #[serde(default)]
    do_not: DoNotFields,
    #[serde(default)]
    free_text: Option<FreeText>,
}

#[derive(Debug, Deserialize, Serialize)]
struct StyleTomlMeta {
    schema_version: u32,
    target_language: String,
    scope: StyleTomlScope,
}

#[derive(Debug, Deserialize, Serialize)]
struct StyleTomlScope {
    kind: GlossaryScopeKind,
    #[serde(skip_serializing_if = "Option::is_none")]
    id: Option<String>,
}

#[derive(Debug, Deserialize, Serialize)]
struct FreeText {
    #[serde(default)]
    instructions: Option<String>,
}

pub async fn run(args: StyleArgs) -> Result<()> {
    let store = JobStore::open_default()?;
    match args.command {
        StyleCommand::List(args) => list_styles(&store, args),
        StyleCommand::Import(args) => import_style(&store, args),
        StyleCommand::Export(args) => export_style(&store, args),
        StyleCommand::Clear(args) => clear_style(&store, args),
        StyleCommand::Show(args) => show_style(&store, args),
    }
}

pub(crate) fn read_style_file(path: &PathBuf) -> Result<StyleSheet> {
    let raw = fs::read_to_string(path)
        .with_context(|| format!("failed to read style sheet {}", path.display()))?;
    parse_style_toml(&raw)
        .with_context(|| format!("failed to parse style sheet {}", path.display()))
}

pub(crate) fn parse_style_toml(raw: &str) -> Result<StyleSheet> {
    let parsed: StyleToml = toml::from_str(raw)?;
    if parsed.meta.schema_version != 1 {
        anyhow::bail!(
            "style sheet schema_version {} is not supported (expected 1)",
            parsed.meta.schema_version
        );
    }
    validate_scope(parsed.meta.scope.kind, parsed.meta.scope.id.as_deref())?;
    let free_text_instructions = parsed.free_text.and_then(|f| f.instructions);
    Ok(StyleSheet {
        scope_kind: parsed.meta.scope.kind,
        scope_id: parsed.meta.scope.id,
        target_language: parsed.meta.target_language,
        register: parsed.register,
        voice: parsed.voice,
        free_text_instructions,
        do_not: parsed.do_not,
    })
}

fn validate_scope(scope: GlossaryScopeKind, scope_id: Option<&str>) -> Result<()> {
    match scope {
        GlossaryScopeKind::Global => Ok(()),
        GlossaryScopeKind::Series | GlossaryScopeKind::Book => {
            if scope_id.is_none() || scope_id.is_some_and(|id| id.is_empty()) {
                anyhow::bail!(
                    "style sheet with scope.kind = {:?} requires a non-empty scope.id",
                    scope
                );
            }
            Ok(())
        }
    }
}

fn import_style(store: &JobStore, args: ImportArgs) -> Result<()> {
    let sheet = read_style_file(&args.file)?;
    let content_toml = fs::read_to_string(&args.file)?;
    let one = vec![sheet.clone()];
    let merged = merge_style_sheets(&one);
    let fp = style_fingerprint(merged.as_ref());
    store.upsert_style_sheet(&NewStyleSheet {
        scope_kind: sheet.scope_kind,
        scope_id: sheet.scope_id.as_deref(),
        target_language: &sheet.target_language,
        content_toml: &content_toml,
        fingerprint: &fp,
    })?;
    println!("Imported style sheet for {}.", sheet.target_language);
    Ok(())
}

fn list_styles(store: &JobStore, args: ListArgs) -> Result<()> {
    let stored = store.list_style_sheets(
        args.language.as_deref(),
        args.scope,
        args.scope_id.as_deref(),
    )?;
    if stored.is_empty() {
        println!("No style sheets matched.");
        return Ok(());
    }
    for record in stored {
        println!(
            "id={} scope={:?} scope_id={:?} target={} fingerprint={}",
            record.id,
            record.scope_kind,
            record.scope_id,
            record.target_language,
            &record.fingerprint[..16]
        );
    }
    Ok(())
}

fn export_style(store: &JobStore, args: ExportArgs) -> Result<()> {
    let records = store.list_style_sheets(
        Some(&args.language),
        Some(args.scope),
        args.scope_id.as_deref(),
    )?;
    let record = records
        .into_iter()
        .next()
        .ok_or_else(|| anyhow::anyhow!("no style sheet matched the export filters"))?;
    fs::write(&args.file, record.content_toml)?;
    println!(
        "Exported style sheet id={} to {}",
        record.id,
        args.file.display()
    );
    Ok(())
}

fn clear_style(store: &JobStore, args: ClearArgs) -> Result<()> {
    let count = store.clear_style_scope(args.scope, args.scope_id.as_deref())?;
    println!("Cleared {count} style sheets.");
    Ok(())
}

fn show_style(store: &JobStore, args: ShowArgs) -> Result<()> {
    let records = store.load_active_style_sheets(
        &args.language,
        args.book_id.as_deref(),
        args.series_id.as_deref(),
    )?;
    if records.is_empty() {
        println!("No active style sheets for the requested scope.");
        return Ok(());
    }
    let mut sheets: Vec<StyleSheet> = Vec::new();
    for record in &records {
        match parse_style_toml(&record.content_toml) {
            Ok(s) => sheets.push(s),
            Err(err) => eprintln!(
                "warn: skipping malformed style sheet id={}: {err}",
                record.id
            ),
        }
    }
    let merged = merge_style_sheets(&sheets);
    let block = render_style_block(merged.as_ref());
    if block.is_empty() {
        println!("Active sheets parsed but produced no content.");
    } else {
        print!("{block}");
    }
    Ok(())
}