mod app;
mod config;
mod evidence;
mod tasks;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use clap::{Parser, Subcommand, ValueEnum};
use futures::stream::{self, StreamExt};
use indicatif::{ProgressBar, ProgressStyle};
use sdsforge_core::{Language, SourceCountry, SdsRoot};
use tasks::{
EvalCorpusParams, LogFn, Provider, Quality, RenderFormat, RenderParams,
ToDocxParams, ToHtmlParams, ToJsonParams, ToPdfParams,
check_json_file_size, collect_files,
};
#[derive(Clone, Copy, ValueEnum, Default)]
enum CliQuality {
Low,
#[default]
Medium,
High,
Max,
}
impl From<CliQuality> for Quality {
fn from(q: CliQuality) -> Self {
match q {
CliQuality::Low => Quality::Low,
CliQuality::Medium => Quality::Medium,
CliQuality::High => Quality::High,
CliQuality::Max => Quality::Max,
}
}
}
#[derive(Clone, Copy, ValueEnum)]
enum CliLanguage {
Ja,
En,
ZhCn,
ZhTw,
}
impl From<CliLanguage> for Language {
fn from(l: CliLanguage) -> Self {
match l {
CliLanguage::Ja => Language::Japanese,
CliLanguage::En => Language::English,
CliLanguage::ZhCn => Language::ChineseSimplified,
CliLanguage::ZhTw => Language::ChineseTraditional,
}
}
}
#[derive(Clone, Copy, ValueEnum)]
enum CliCountry {
Jp,
Cn,
Tw,
Kr,
}
impl From<CliCountry> for SourceCountry {
fn from(c: CliCountry) -> Self {
match c {
CliCountry::Jp => SourceCountry::Japan,
CliCountry::Cn => SourceCountry::China,
CliCountry::Tw => SourceCountry::Taiwan,
CliCountry::Kr => SourceCountry::Korea,
}
}
}
#[derive(Clone, Copy, ValueEnum, Default)]
enum CliProvider {
#[default]
Anthropic,
Openai,
Gemini,
Mistral,
Groq,
Cohere,
Local,
}
impl From<CliProvider> for Provider {
fn from(p: CliProvider) -> Self {
match p {
CliProvider::Anthropic => Provider::Anthropic,
CliProvider::Openai => Provider::Openai,
CliProvider::Gemini => Provider::Gemini,
CliProvider::Mistral => Provider::Mistral,
CliProvider::Groq => Provider::Groq,
CliProvider::Cohere => Provider::Cohere,
CliProvider::Local => Provider::Local,
}
}
}
#[derive(Clone, Copy, ValueEnum)]
enum CliRenderFormat {
Docx,
Html,
Pdf,
}
impl From<CliRenderFormat> for RenderFormat {
fn from(f: CliRenderFormat) -> Self {
match f {
CliRenderFormat::Docx => RenderFormat::Docx,
CliRenderFormat::Html => RenderFormat::Html,
CliRenderFormat::Pdf => RenderFormat::Pdf,
}
}
}
#[derive(Parser)]
#[command(name = "sdsforge", about = "Generate, convert, translate, and validate SDS documents against MHLW standard JSON")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Clone, Copy, ValueEnum, Default)]
enum CliGenerationProfile {
#[default]
MhlwV1,
}
#[derive(Subcommand)]
enum Commands {
Generate {
#[arg(short, long)]
input: PathBuf,
#[arg(short, long)]
output_dir: PathBuf,
#[arg(long, value_enum, default_value = "mhlw-v1")]
profile: CliGenerationProfile,
#[arg(long)]
enrich: bool,
#[arg(long)]
strict: bool,
#[arg(long)]
force: bool,
},
ToJson {
#[arg(short, long, conflicts_with = "input_dir")]
input: Option<String>,
#[arg(long, conflicts_with = "input")]
input_dir: Option<PathBuf>,
#[arg(short, long, conflicts_with = "output_dir")]
output: Option<PathBuf>,
#[arg(long, conflicts_with = "output")]
output_dir: Option<PathBuf>,
#[arg(long, hide_env_values = true)]
api_key: Option<String>,
#[arg(long, value_enum)]
lang: Option<CliLanguage>,
#[arg(long, value_enum)]
country: Option<CliCountry>,
#[arg(long)]
model: Option<String>,
#[arg(long, value_enum, default_value = "anthropic")]
provider: CliProvider,
#[arg(long)]
base_url: Option<String>,
#[arg(long, value_enum, default_value = "medium")]
quality: CliQuality,
#[arg(long, default_value = "4")]
concurrency: usize,
#[arg(long)]
enrich: bool,
#[arg(long)]
correct: bool,
#[arg(long)]
suggested_name: bool,
},
Render {
#[arg(short, long, conflicts_with = "input_dir")]
input: Option<PathBuf>,
#[arg(long, conflicts_with = "input")]
input_dir: Option<PathBuf>,
#[arg(short, long, conflicts_with = "output_dir")]
output: Option<PathBuf>,
#[arg(long, conflicts_with = "output")]
output_dir: Option<PathBuf>,
#[arg(long, value_enum)]
to: CliRenderFormat,
#[arg(long, value_enum, default_value = "ja")]
lang: CliLanguage,
#[arg(long)]
template: Option<PathBuf>,
},
ToDocx {
#[arg(short, long, conflicts_with = "input_dir")]
input: Option<PathBuf>,
#[arg(long, conflicts_with = "input")]
input_dir: Option<PathBuf>,
#[arg(short, long, conflicts_with = "output_dir")]
output: Option<PathBuf>,
#[arg(long, conflicts_with = "output")]
output_dir: Option<PathBuf>,
#[arg(long, value_enum, default_value = "ja")]
lang: CliLanguage,
#[arg(long)]
template: Option<PathBuf>,
},
Validate {
#[arg(short, long)]
input: PathBuf,
#[arg(long)]
json: bool,
#[arg(long)]
strict_mhlw: bool,
},
ToHtml {
#[arg(short, long, conflicts_with = "input_dir")]
input: Option<PathBuf>,
#[arg(long, conflicts_with = "input")]
input_dir: Option<PathBuf>,
#[arg(short, long, conflicts_with = "output_dir")]
output: Option<PathBuf>,
#[arg(long, conflicts_with = "output")]
output_dir: Option<PathBuf>,
#[arg(long, value_enum, default_value = "ja")]
lang: CliLanguage,
},
ToPdf {
#[arg(short, long, conflicts_with = "input_dir")]
input: Option<PathBuf>,
#[arg(long, conflicts_with = "input")]
input_dir: Option<PathBuf>,
#[arg(short, long, conflicts_with = "output_dir")]
output: Option<PathBuf>,
#[arg(long, conflicts_with = "output")]
output_dir: Option<PathBuf>,
#[arg(long, value_enum, default_value = "ja")]
lang: CliLanguage,
},
ExtractText {
#[arg(short, long)]
input: String,
#[arg(short, long)]
output: Option<PathBuf>,
},
DetectLang {
#[arg(short, long)]
input: String,
},
EvalCorpus {
#[arg(long)]
input_dir: PathBuf,
#[arg(long)]
output_dir: PathBuf,
#[arg(long, default_value = "8")]
jobs: usize,
#[arg(long)]
strict_mhlw: bool,
#[arg(long)]
correct: bool,
#[arg(long)]
enrich: bool,
#[arg(long, value_enum)]
lang: Option<CliLanguage>,
#[arg(long, value_enum)]
country: Option<CliCountry>,
#[arg(long, value_enum, default_value = "anthropic")]
provider: CliProvider,
#[arg(long, env = "ANTHROPIC_API_KEY")]
api_key: Option<String>,
#[arg(long, default_value = "claude-sonnet-4-5")]
model: String,
#[arg(long, value_enum, default_value = "medium")]
quality: CliQuality,
#[arg(long)]
base_url: Option<String>,
#[arg(long)]
max_files: Option<usize>,
#[arg(long)]
qc_script: Option<PathBuf>,
},
}
pub fn init_process() {
{
let default_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let file = info.location().map(|l| l.file()).unwrap_or("");
if file.contains("pdf-extract") || file.contains("pdf_extract") {
return;
}
default_hook(info);
}));
}
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")),
)
.with_writer(std::io::stderr)
.init();
}
pub fn run_gui() -> anyhow::Result<()> {
app::run_gui()
}
pub async fn run_cli_from<I, T>(args: I) -> anyhow::Result<()>
where
I: IntoIterator<Item = T>,
T: Into<OsString> + Clone,
{
let cli = Cli::parse_from(args);
run_cli(cli).await
}
async fn run_cli(cli: Cli) -> anyhow::Result<()> {
let cfg = config::AppConfig::load();
let log: LogFn = tasks::stdout_log();
match cli.command {
Commands::Generate {
input, output_dir, profile: CliGenerationProfile::MhlwV1,
enrich, strict, force,
} => {
let outcome = tasks::run_generate(
tasks::GenerateParams { input, output_dir, enrich, force },
Arc::clone(&log),
).await?;
if outcome.release_status == sdsforge_core::ReleaseStatus::Blocked {
eprintln!(
"Release status: BLOCKED ({} blocking finding(s), {} unresolved field(s)) — see review_report.md",
outcome.blocking_findings_count, outcome.unresolved_count
);
}
if strict && outcome.release_status == sdsforge_core::ReleaseStatus::Blocked {
std::process::exit(1);
}
}
Commands::ToJson {
input, input_dir, output, output_dir,
api_key, lang, country, model, provider, base_url, quality, concurrency,
enrich, correct, suggested_name,
} => {
let provider = Provider::from(provider);
let quality = Quality::from(quality);
let api_key = resolve_api_key(api_key, provider, &cfg)?;
let model = model.unwrap_or_else(|| provider.default_model(quality).to_string());
let country = country.map(SourceCountry::from);
eprintln!(
"Quality: {} (max_chars={}, max_tokens={}, model={})",
quality.label(), quality.max_chars(), quality.max_tokens(), model
);
match (input, input_dir) {
(Some(input), None) => {
let output = output.ok_or_else(|| anyhow::anyhow!("--output required"))?;
tasks::run_to_json(ToJsonParams {
input, output, provider, api_key, model, quality,
lang: lang.map(Language::from), country, base_url, enrich, correct,
use_suggested_filename: suggested_name,
}, Arc::clone(&log)).await?;
}
(None, Some(dir)) => {
let out_dir = output_dir.ok_or_else(|| anyhow::anyhow!("--output-dir required"))?;
std::fs::create_dir_all(&out_dir)?;
batch_to_json(
&dir, &out_dir, provider, api_key, model, quality,
lang.map(Language::from), country, base_url, concurrency, enrich, correct,
suggested_name,
).await;
}
_ => anyhow::bail!("Specify either --input or --input-dir"),
}
}
Commands::Render { input, input_dir, output, output_dir, to, lang, template } => {
let lang = Language::from(lang);
let format = RenderFormat::from(to);
match (input, input_dir) {
(Some(input), None) => {
let output = output.ok_or_else(|| anyhow::anyhow!("--output required"))?;
tasks::run_render(
RenderParams { input, output, lang, format, template },
Arc::clone(&log),
).await?;
}
(None, Some(dir)) => {
let out_dir = output_dir.ok_or_else(|| anyhow::anyhow!("--output-dir required"))?;
std::fs::create_dir_all(&out_dir)?;
match format {
RenderFormat::Docx => batch_to_docx(&dir, &out_dir, lang, template.as_deref())?,
RenderFormat::Html => batch_to_html(&dir, &out_dir, lang)?,
RenderFormat::Pdf => batch_to_pdf(&dir, &out_dir, lang)?,
}
}
_ => anyhow::bail!("Specify either --input or --input-dir"),
}
}
Commands::ToDocx { input, input_dir, output, output_dir, lang, template } => {
eprintln!("warning: `sdsforge to-docx` is deprecated; use `sdsforge render --to docx`");
match (input, input_dir) {
(Some(input), None) => {
let output = output.ok_or_else(|| anyhow::anyhow!("--output required"))?;
tasks::run_to_docx(
ToDocxParams { input, output, lang: Language::from(lang), template },
Arc::clone(&log),
).await?;
}
(None, Some(dir)) => {
let out_dir = output_dir.ok_or_else(|| anyhow::anyhow!("--output-dir required"))?;
std::fs::create_dir_all(&out_dir)?;
batch_to_docx(&dir, &out_dir, Language::from(lang), template.as_deref())?;
}
_ => anyhow::bail!("Specify either --input or --input-dir"),
}
}
Commands::Validate { input, json, strict_mhlw } => {
let findings = tasks::run_validate(input, Arc::clone(&log)).await?;
if json {
println!("{}", serde_json::to_string_pretty(&findings)?);
}
if strict_mhlw {
let has_high_or_crit = findings
.iter()
.any(|f| f.level == "HIGH" || f.level == "CRIT");
if has_high_or_crit {
eprintln!("strict-mhlw: HIGH/CRIT findings present — see warnings above.");
std::process::exit(1);
}
} else if !findings.is_empty() && !json {
std::process::exit(1);
}
}
Commands::ToHtml { input, input_dir, output, output_dir, lang } => {
eprintln!("warning: `sdsforge to-html` is deprecated; use `sdsforge render --to html`");
match (input, input_dir) {
(Some(input), None) => {
let output = output.ok_or_else(|| anyhow::anyhow!("--output required"))?;
tasks::run_to_html(
ToHtmlParams { input, output, lang: Language::from(lang) },
Arc::clone(&log),
).await?;
}
(None, Some(dir)) => {
let out_dir = output_dir.ok_or_else(|| anyhow::anyhow!("--output-dir required"))?;
std::fs::create_dir_all(&out_dir)?;
batch_to_html(&dir, &out_dir, Language::from(lang))?;
}
_ => anyhow::bail!("Specify either --input or --input-dir"),
}
}
Commands::ToPdf { input, input_dir, output, output_dir, lang } => {
eprintln!("warning: `sdsforge to-pdf` is deprecated; use `sdsforge render --to pdf`");
match (input, input_dir) {
(Some(input), None) => {
let output = output.ok_or_else(|| anyhow::anyhow!("--output required"))?;
tasks::run_to_pdf(
ToPdfParams { input, output, lang: Language::from(lang) },
Arc::clone(&log),
).await?;
}
(None, Some(dir)) => {
let out_dir = output_dir.ok_or_else(|| anyhow::anyhow!("--output-dir required"))?;
std::fs::create_dir_all(&out_dir)?;
batch_to_pdf(&dir, &out_dir, Language::from(lang))?;
}
_ => anyhow::bail!("Specify either --input or --input-dir"),
}
}
Commands::ExtractText { input, output } => {
use anyhow::Context as _;
use sdsforge_core::{extract_text, extract_text_from_url};
let is_url = input.starts_with("http://") || input.starts_with("https://");
let text = if is_url {
extract_text_from_url(&input).await?
} else {
extract_text(Path::new(&input)).await?
};
match output {
Some(path) => {
std::fs::write(&path, &text)
.with_context(|| format!("writing {}", path.display()))?;
eprintln!("Saved text to {}", path.display());
}
None => print!("{text}"),
}
}
Commands::DetectLang { input } => {
use sdsforge_core::{detect_language_from_file, detect_language_from_url};
let is_url = input.starts_with("http://") || input.starts_with("https://");
let lang = if is_url {
detect_language_from_url(&input).await?
} else {
detect_language_from_file(Path::new(&input)).await?
};
println!("{} ({})", lang.name_en(), lang.bcp47());
}
Commands::EvalCorpus {
input_dir, output_dir, jobs, strict_mhlw, correct, enrich,
lang, country, provider, api_key, model, quality, base_url,
max_files, qc_script,
} => {
let provider = Provider::from(provider);
let api_key = resolve_api_key(api_key, provider, &cfg)?;
let lang = lang.map(Language::from);
let country = country.map(SourceCountry::from);
let qc_script = qc_script.unwrap_or_else(|| PathBuf::from("tools/quality_check.py"));
std::fs::create_dir_all(&output_dir)?;
tasks::run_eval_corpus(EvalCorpusParams {
input_dir, output_dir, provider, api_key,
model, quality: Quality::from(quality),
lang, country, base_url, jobs, correct, enrich, strict_mhlw,
max_files, qc_script,
}, log).await?;
}
}
Ok(())
}
fn resolve_api_key(
cli_key: Option<String>,
provider: Provider,
cfg: &config::AppConfig,
) -> anyhow::Result<String> {
if let Some(k) = cli_key {
eprintln!(
"Warning: passing --api-key on the command line may expose it to other local \
processes. Prefer setting the environment variable instead."
);
return Ok(k);
}
let env_var = provider.api_key_env();
if let Ok(k) = std::env::var(env_var) {
return Ok(k);
}
if provider == Provider::Local {
return Ok("ollama".to_string());
}
if !cfg.api_key.is_empty() && Provider::from_str(&cfg.provider) == provider {
return Ok(cfg.api_key.clone());
}
anyhow::bail!("API key not provided. Set --api-key or {env_var}")
}
fn make_pb(total: u64) -> Arc<ProgressBar> {
let pb = ProgressBar::new(total);
pb.set_style(
ProgressStyle::with_template("[{elapsed_precise}] {bar:40.cyan/blue} {pos}/{len} {msg}")
.unwrap_or_else(|_| ProgressStyle::default_bar()),
);
Arc::new(pb)
}
#[allow(clippy::too_many_arguments)]
async fn batch_to_json(
input_dir: &Path,
output_dir: &Path,
provider: Provider,
api_key: String,
model: String,
quality: Quality,
lang: Option<Language>,
country: Option<SourceCountry>,
base_url: Option<String>,
concurrency: usize,
enrich: bool,
correct: bool,
use_suggested_filename: bool,
) {
let files = collect_files(input_dir, &["pdf", "docx", "xlsx", "xls"]);
let total = files.len();
if total == 0 {
eprintln!("No files found in {}", input_dir.display());
return;
}
let pb = make_pb(total as u64);
let output_dir = Arc::new(output_dir.to_path_buf());
let ok = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let failed = Arc::new(std::sync::atomic::AtomicUsize::new(0));
stream::iter(files)
.map(|path| {
let pb = Arc::clone(&pb);
let output_dir = Arc::clone(&output_dir);
let ok = Arc::clone(&ok);
let failed = Arc::clone(&failed);
let api_key = api_key.clone();
let model = model.clone();
let base_url = base_url.clone();
async move {
let stem = match path.file_stem().and_then(|s| s.to_str()).filter(|s| !s.is_empty()) {
Some(s) => s.to_string(),
None => {
pb.println(format!("[SKIP] {}: no file stem", path.display()));
failed.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
pb.inc(1);
return;
}
};
let out_path = output_dir.join(format!("{stem}.json"));
pb.set_message(stem.clone());
let pb2 = Arc::clone(&pb);
let log: LogFn = Arc::new(move |msg| { pb2.println(msg); });
let result = tasks::run_to_json(ToJsonParams {
input: path.to_string_lossy().into_owned(),
output: out_path,
provider, api_key, model, quality, lang, country, base_url, enrich, correct,
use_suggested_filename,
}, log).await;
match result {
Ok(_) => { ok.fetch_add(1, std::sync::atomic::Ordering::Relaxed); }
Err(e) => {
pb.println(format!("[ERROR] {}: {e}", path.display()));
failed.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
}
pb.inc(1);
}
})
.buffer_unordered(concurrency.max(1))
.collect::<Vec<_>>()
.await;
let ok = ok.load(std::sync::atomic::Ordering::Relaxed);
let failed = failed.load(std::sync::atomic::Ordering::Relaxed);
pb.finish_and_clear();
eprintln!("Done: {ok} ok, {failed} failed");
}
fn batch_to_docx(
input_dir: &Path,
output_dir: &Path,
lang: Language,
template: Option<&Path>,
) -> anyhow::Result<()> {
use sdsforge_core::{convert_from_json, convert_from_template, ConvertConfig};
let files = collect_files(input_dir, &["json"]);
let total = files.len();
if total == 0 { eprintln!("No .json files found"); return Ok(()); }
let pb = make_pb(total as u64);
let (mut ok, mut failed) = (0usize, 0usize);
for path in &files {
let stem = match path.file_stem().and_then(|s| s.to_str()).filter(|s| !s.is_empty()) {
Some(s) => s.to_string(),
None => { failed += 1; pb.inc(1); continue; }
};
pb.set_message(stem.clone());
let out_path = output_dir.join(format!("{stem}.docx"));
let result = check_json_file_size(path)
.and_then(|_| std::fs::read_to_string(path).map_err(anyhow::Error::from))
.and_then(|raw| {
let effective_lang = if lang == Language::default() {
sdsforge_core::detect_language(&raw)
} else {
lang
};
let file_config = ConvertConfig {
source_language: None,
output_language: effective_lang,
..Default::default()
};
serde_json::from_str::<SdsRoot>(&raw)
.map_err(anyhow::Error::from)
.and_then(|sds| {
if let Some(tmpl) = template {
convert_from_template(&sds, tmpl, &out_path).map_err(anyhow::Error::from)
} else {
convert_from_json(&sds, &out_path, &file_config).map_err(anyhow::Error::from)
}
})
});
match result {
Ok(_) => ok += 1,
Err(e) => { pb.println(format!("[ERROR] {}: {e}", path.display())); failed += 1; }
}
pb.inc(1);
}
pb.finish_and_clear();
eprintln!("Done: {ok} ok, {failed} failed");
Ok(())
}
fn batch_to_html(input_dir: &Path, output_dir: &Path, lang: Language) -> anyhow::Result<()> {
use sdsforge_core::{converter::html::render_html, ConvertConfig};
let files = collect_files(input_dir, &["json"]);
let total = files.len();
if total == 0 { eprintln!("No .json files found"); return Ok(()); }
let pb = make_pb(total as u64);
let config = ConvertConfig { source_language: None, output_language: lang, ..Default::default() };
let (mut ok, mut failed) = (0usize, 0usize);
for path in &files {
let stem = match path.file_stem().and_then(|s| s.to_str()).filter(|s| !s.is_empty()) {
Some(s) => s.to_string(),
None => { failed += 1; pb.inc(1); continue; }
};
pb.set_message(stem.clone());
let out_path = output_dir.join(format!("{stem}.html"));
let result = check_json_file_size(path)
.and_then(|_| std::fs::read_to_string(path).map_err(anyhow::Error::from))
.and_then(|raw| serde_json::from_str::<SdsRoot>(&raw).map_err(anyhow::Error::from))
.and_then(|sds| render_html(&sds, config.output_language).map_err(anyhow::Error::from))
.and_then(|html| std::fs::write(&out_path, html).map_err(anyhow::Error::from));
match result {
Ok(_) => ok += 1,
Err(e) => { pb.println(format!("[ERROR] {}: {e}", path.display())); failed += 1; }
}
pb.inc(1);
}
pb.finish_and_clear();
eprintln!("Done: {ok} ok, {failed} failed");
Ok(())
}
fn batch_to_pdf(input_dir: &Path, output_dir: &Path, lang: Language) -> anyhow::Result<()> {
let files = collect_files(input_dir, &["json"]);
let total = files.len();
if total == 0 { eprintln!("No .json files found"); return Ok(()); }
let pb = make_pb(total as u64);
let (mut ok, mut failed) = (0usize, 0usize);
for path in &files {
let stem = match path.file_stem().and_then(|s| s.to_str()).filter(|s| !s.is_empty()) {
Some(s) => s.to_string(),
None => { failed += 1; pb.inc(1); continue; }
};
pb.set_message(stem.clone());
let out_path = output_dir.join(format!("{stem}.pdf"));
let result = check_json_file_size(path)
.and_then(|_| std::fs::read_to_string(path).map_err(anyhow::Error::from))
.and_then(|raw| serde_json::from_str::<SdsRoot>(&raw).map_err(anyhow::Error::from))
.and_then(|sds| {
sdsforge_core::converter::render_pdf(&sds, lang)
.map_err(anyhow::Error::from)
.and_then(|bytes| std::fs::write(&out_path, bytes).map_err(anyhow::Error::from))
});
match result {
Ok(_) => ok += 1,
Err(e) => { pb.println(format!("[ERROR] {}: {e}", path.display())); failed += 1; }
}
pb.inc(1);
}
pb.finish_and_clear();
eprintln!("Done: {ok} ok, {failed} failed");
Ok(())
}