refactor(cli): 模块化 Rust 前台输出
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s

This commit is contained in:
2026-09-02 17:42:16 +08:00
parent bd1e1a06f2
commit 34e2f0d907
20 changed files with 1461 additions and 1407 deletions
File diff suppressed because it is too large Load Diff
+4
View File
@@ -1,3 +1,7 @@
use super::report_output::{
format_daemon_download_progress_json, verification_summary_lines,
visible_historical_failed_versions,
};
use super::*;
use crate::app::schedule_commands::{
read_schedule_file, schedule_add_report, schedule_list_report_with_request,
@@ -1,3 +1,5 @@
use super::report_output::print_report;
pub(super) fn run_write_patch_command(options: &CliOptions) -> anyhow::Result<()> {
match options.command {
CliCommand::PatchApply => {
@@ -1,3 +1,4 @@
use super::report_output::print_json_value;
use super::*;
pub(super) fn run_readonly_query_command(options: &CliOptions) -> anyhow::Result<()> {
+850
View File
@@ -0,0 +1,850 @@
use super::*;
pub(super) trait HumanReport {
fn print_human(&self) -> anyhow::Result<()>;
}
pub(super) fn print_report<T>(format: OutputFormat, report: &T) -> anyhow::Result<()>
where
T: Serialize + HumanReport,
{
match format {
OutputFormat::Json => println!("{}", serde_json::to_string_pretty(report)?),
OutputFormat::Human => report.print_human()?,
}
Ok(())
}
pub(super) fn print_json_value(
format: OutputFormat,
value: &serde_json::Value,
) -> anyhow::Result<()> {
match format {
OutputFormat::Json => println!("{}", serde_json::to_string_pretty(value)?),
OutputFormat::Human => print_human_json_value(value)?,
}
Ok(())
}
impl HumanReport for serde_json::Value {
fn print_human(&self) -> anyhow::Result<()> {
print_human_json_value(self)
}
}
impl HumanReport for RepackReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title("UnityFS 重打包");
print_field("命令", self.command);
print_field("状态", self.status);
print_path_field("源 bundle", &self.source_bundle);
print_path_field("目标 bundle", &self.target_bundle);
print_field("操作数", self.operation_count);
print_field("源字节", self.source_bytes);
print_field("目标字节", self.target_bytes);
print_field("源 BLAKE3", &self.source_blake3);
print_field("目标 BLAKE3", &self.target_blake3);
Ok(())
}
}
impl HumanReport for LocalizedPatchReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title("汉化 release 发布");
print_path_field("版本目录", &self.version_path);
print_path_field("current", &self.current_path);
print_path_field("状态文件", &self.state_path);
print_path_field("patch manifest", &self.patch_manifest_path);
print_field("变更文件数", self.files.len());
print_field("TextAsset 操作数", self.manifest.text_asset_operation_count);
print_field("校验文件数", self.integrity.verified_changed_file_count);
Ok(())
}
}
impl HumanReport for LocalizedRollbackReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title("汉化 release 回滚");
print_field("命令", self.command);
print_field("状态", self.status);
print_field("回滚 release", &self.rolled_back_release_id);
print_optional_field("恢复 release", self.restored_release_id.as_deref());
print_path_field("汉化输出目录", &self.localized_output_root);
print_path_field("current", &self.current_path);
print_path_field("状态文件", &self.state_path);
print_path_field("删除版本目录", &self.removed_version_path);
print_optional_path_field("恢复 current 目标", self.restored_current_target.as_ref());
print_field("新状态", &self.state.status);
print_optional_field("当前 release", self.state.current_release_id.as_deref());
Ok(())
}
}
impl HumanReport for bat_infrastructure::LocalizedTranslationWorkflowReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title("汉化工作流状态");
print_field("命令", self.command);
print_field("状态", self.status);
print_field("官方 release", &self.official_release_id);
print_optional_field("汉化 release", self.current_release_id.as_deref());
print_field("汉化发布状态", &self.localized_release_status);
print_field("工作流状态", &self.translation_workflow_status);
print_field("工作流状态码", self.translation_workflow_status_code);
print_field("工作流标签", self.translation_workflow_label);
print_field("允许发布", format_bool(self.publish_allowed));
print_path_field("汉化输出目录", &self.localized_output_root);
print_path_field("状态文件", &self.state_path);
Ok(())
}
}
impl HumanReport for bat_infrastructure::TranslationWorkerReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title("翻译 provider worker");
print_field("命令", self.command);
print_field("状态", self.status);
print_field("官方 release", &self.official_release_id);
print_field("provider", &self.provider);
print_field("回收 lease", self.recovered_lease_count);
print_field("领取任务", self.claimed_count);
print_field("完成任务", self.completed_count);
print_field("失败任务", self.failed_count);
print_field("已安排重试", self.retry_scheduled_count);
print_field("剩余任务", self.remaining_count);
for failure in &self.failures {
println!(
" - {} [{}] retryable={} {}",
failure.task_id,
failure.failure_class,
format_bool(failure.retryable),
failure.failure_reason
);
}
Ok(())
}
}
fn print_human_json_value(value: &serde_json::Value) -> anyhow::Result<()> {
if value.get("running").is_some() && value.get("state_dir").is_some() {
print_title("后台状态");
print_json_field(value, "status", "状态");
print_json_field(value, "message", "消息");
print_json_field(value, "running", "运行中");
print_json_field(value, "pid", "PID");
print_json_field(value, "daemon_state", "后台状态");
print_json_field(value, "rpc_available", "RPC 可用");
print_json_field(value, "stale_pid_file", "失效 PID");
print_json_field(value, "stale_socket", "失效 socket");
print_json_field(value, "last_update_status", "上次同步");
print_json_field(value, "last_success_unix_seconds", "最后成功时间");
print_json_field(value, "last_error", "上次错误");
print_json_field(value, "next_retry_seconds", "下次重试秒数");
print_json_field(value, "next_check_unix_seconds", "下次检查时间");
print_json_field(value, "current_stage", "当前阶段");
print_json_field(value, "current_message", "当前消息");
print_daemon_download_progress_json_summary(value.get("download_progress"));
print_json_field(value, "version_state_path", "版本状态路径");
print_daemon_version_state_json_summary(value.get("version_state"));
print_json_field(value, "resource_output_root", "资源目录");
print_json_field(value, "state_dir", "状态目录");
print_json_field(value, "socket_path", "socket");
print_json_field(value, "log_path", "日志");
print_json_field(value, "structured_log_path", "结构化日志");
print_json_field(value, "rotated_structured_log_paths", "轮转日志");
return Ok(());
}
if value.get("command").and_then(serde_json::Value::as_str) == Some("logs") {
print_title("后台日志");
print_json_field(value, "status", "状态");
print_json_field(value, "message", "消息");
print_json_field(value, "log_path", "日志");
print_json_field(value, "bytes", "字节");
print_json_field(value, "total_lines", "总行数");
print_json_field(value, "returned_lines", "返回行数");
if let Some(content) = value.get("content").and_then(serde_json::Value::as_str) {
if !content.is_empty() {
println!();
println!("{content}");
}
}
return Ok(());
}
if value.get("command").is_some() && value.get("status").is_some() {
print_title("后台命令");
print_json_field(value, "command", "命令");
print_json_field(value, "status", "状态");
print_json_field(value, "message", "消息");
print_json_field(value, "force", "force");
print_json_field(value, "state_dir", "状态目录");
print_json_field(value, "socket_path", "socket");
return Ok(());
}
println!("{}", serde_json::to_string_pretty(value)?);
Ok(())
}
fn print_daemon_download_progress_json_summary(value: Option<&serde_json::Value>) {
let Some(value) = value else {
return;
};
if value.is_null() {
return;
}
print_field(
"下载进度",
format_daemon_download_progress_json(value)
.unwrap_or_else(|error| format!("无法解析:{error}")),
);
}
pub(super) fn format_daemon_download_progress_json(
value: &serde_json::Value,
) -> Result<String, String> {
let progress = serde_json::from_value::<DaemonDownloadProgress>(value.clone())
.map_err(|error| error.to_string())?;
Ok(format_daemon_download_progress(&progress))
}
fn print_daemon_version_state_json_summary(value: Option<&serde_json::Value>) {
let Some(value) = value else {
return;
};
if value.is_null() {
return;
}
match serde_json::from_value::<OfficialVersionState>(value.clone()) {
Ok(version_state) => print_daemon_version_state_summary(&version_state),
Err(error) => print_field("版本状态", format!("无法解析:{error}")),
}
}
fn print_daemon_version_state_summary(version_state: &OfficialVersionState) {
print_optional_field(
"当前完成版本",
version_state
.current_completed_version
.as_ref()
.map(|version| version.id.as_str()),
);
print_optional_field(
"正在拉取版本",
version_state
.in_progress_version
.as_ref()
.map(|version| version.id.as_str()),
);
print_optional_field(
"上一个可用版本",
version_state
.previous_available_version
.as_ref()
.map(|version| version.id.as_str()),
);
let historical_failures = visible_historical_failed_versions(version_state);
print_field("历史失败版本数", historical_failures.len());
if let Some(failed) = historical_failures.last() {
print_field("最近历史失败版本", &failed.version.id);
print_field("最近历史失败时间", failed.failed_unix_seconds);
print_field("最近历史失败原因", &failed.error);
}
}
pub(super) fn visible_historical_failed_versions(
version_state: &OfficialVersionState,
) -> Vec<&OfficialFailedVersionRecord> {
let in_progress = version_state.in_progress_version.as_ref();
version_state
.failed_versions
.iter()
.filter(|failed| {
!in_progress.is_some_and(|version| version_matches_for_status(&failed.version, version))
})
.collect()
}
fn version_matches_for_status(left: &OfficialVersionRecord, right: &OfficialVersionRecord) -> bool {
left.app_version == right.app_version
&& left.bundle_version == right.bundle_version
&& left.addressables_root == right.addressables_root
}
fn print_json_field(value: &serde_json::Value, key: &str, label: &str) {
let Some(value) = value.get(key) else {
return;
};
if value.is_null() {
return;
}
if let Some(value) = value.as_str() {
print_field(label, value);
} else {
print_field(label, value);
}
}
fn print_title(title: &str) {
println!("{title}");
}
fn print_field(label: &str, value: impl std::fmt::Display) {
println!(" {label:<18} {value}");
}
fn print_optional_field<T>(label: &str, value: Option<T>)
where
T: std::fmt::Display,
{
if let Some(value) = value {
print_field(label, value);
}
}
fn print_path_field(label: &str, value: &Path) {
print_field(label, value.display());
}
fn print_optional_path_field(label: &str, value: Option<&PathBuf>) {
if let Some(value) = value {
print_path_field(label, value);
}
}
fn print_list(label: &str, values: &[String], limit: usize) {
if values.is_empty() {
return;
}
println!(" {label}:");
for value in values.iter().take(limit) {
println!(" - {value}");
}
if values.len() > limit {
println!(" ... 还有 {}", values.len() - limit);
}
}
fn format_daemon_download_progress(progress: &DaemonDownloadProgress) -> String {
let status = progress.status.as_deref().unwrap_or("running");
if let Some(hash) = progress.official_hash.as_ref() {
return format!(
"{}/{} official_hash algorithm={} expected={} actual={} data={} hash={}",
progress.index,
progress.total,
hash.algorithm.as_str(),
hash.expected,
hash.actual,
hash.data_url,
hash.hash_url
);
}
if status == "failed" {
return format!(
"{}/{} failed kind={} http={} retryable={} attempts={} quarantined={} {}",
progress.index,
progress.total,
progress.failure_kind.as_deref().unwrap_or("unknown"),
progress
.failure_http_status
.map(|status| status.to_string())
.unwrap_or_else(|| "none".to_string()),
progress
.failure_retryable
.map(|retryable| retryable.to_string())
.unwrap_or_else(|| "unknown".to_string()),
progress
.failure_attempts
.map(|attempts| attempts.to_string())
.unwrap_or_else(|| "0".to_string()),
progress
.quarantined
.map(|quarantined| quarantined.to_string())
.unwrap_or_else(|| "false".to_string()),
progress.url
);
}
if let Some(verification) = progress.verification.as_ref() {
return format!(
"{}/{} {} bytes={} blake3={} zip_checked={} zip_verified={} {}",
progress.index,
progress.total,
status,
verification.actual_bytes,
verification.actual_blake3,
verification.zip_checked,
verification.zip_structure_verified,
progress.url
);
}
format!(
"{}/{} {} {}",
progress.index, progress.total, status, progress.url
)
}
fn print_verification_summary(summary: &OfficialVerificationSummary) {
println!(" 校验摘要:");
for line in verification_summary_lines(summary) {
println!(" - {line}");
}
}
pub(super) fn verification_summary_lines(summary: &OfficialVerificationSummary) -> Vec<String> {
vec![
format!(
"官方 .hash 强校验: {} 对 ({})",
summary.official_hash_verified_count, summary.official_hash_scope
),
format!(
"本地 BLAKE3 复用校验: {} 项通过, {} 项需修复 ({})",
summary.local_manifest_blake3_verified_count,
summary.local_manifest_repair_needed_count,
summary.local_manifest_blake3_scope
),
format!(
"ZIP 结构校验: {} 个 ZIP 通过 ({})",
summary.zip_structure_verified_count, summary.zip_structure_scope
),
]
}
fn format_bool(value: bool) -> &'static str {
if value {
"yes"
} else {
"no"
}
}
fn format_bytes(value: u64) -> String {
const KIB: f64 = 1024.0;
const MIB: f64 = 1024.0 * 1024.0;
const GIB: f64 = 1024.0 * 1024.0 * 1024.0;
let value_f = value as f64;
if value_f >= GIB {
format!("{value_f:.2} GiB", value_f = value_f / GIB)
} else if value_f >= MIB {
format!("{value_f:.2} MiB", value_f = value_f / MIB)
} else if value_f >= KIB {
format!("{value_f:.2} KiB", value_f = value_f / KIB)
} else {
format!("{value} B")
}
}
fn platform_label(platform: PatchPlatform) -> &'static str {
match platform {
PatchPlatform::Windows => "Windows",
PatchPlatform::Android => "Android",
}
}
fn endpoint_kind_label_for_human(kind: YostarJpResourceEndpointKind) -> &'static str {
match kind {
YostarJpResourceEndpointKind::TableCatalog => "table_catalog",
YostarJpResourceEndpointKind::TableCatalogHash => "table_catalog_hash",
YostarJpResourceEndpointKind::AddressablesCatalog => "addressables_catalog",
YostarJpResourceEndpointKind::AddressablesCatalogHash => "addressables_catalog_hash",
YostarJpResourceEndpointKind::BundlePackingInfo => "bundle_packing_info",
YostarJpResourceEndpointKind::BundlePackingInfoHash => "bundle_packing_info_hash",
YostarJpResourceEndpointKind::MediaCatalog => "media_catalog",
YostarJpResourceEndpointKind::MediaCatalogHash => "media_catalog_hash",
}
}
impl HumanReport for OfficialUpdateReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title("官方资源同步");
print_field("状态", self.update_status.as_str());
print_field("状态码", self.status_code.as_str());
print_field("应用版本", &self.app_version);
print_optional_field("Bundle 版本", self.bundle_version.as_deref());
print_field("连接组", &self.connection_group);
print_field(
"平台",
self.platforms
.iter()
.map(|platform| platform_label(*platform))
.collect::<Vec<_>>()
.join(", "),
);
print_field("需要下载", format_bool(self.should_download));
print_field(
"等待官方资源",
format_bool(self.waiting_for_official_resources),
);
print_field("首次同步", format_bool(self.is_initial));
print_field("强制刷新", format_bool(self.force));
print_field("本地审计", format_bool(self.audit_local));
print_field("自动修复", format_bool(self.repair));
print_field("dry-run", format_bool(self.dry_run));
print_path_field("官方资源目录", &self.output_root);
print_path_field("汉化输出目录", &self.localized_output_root);
print_field("汉化发布状态", self.localized_release_status.as_str());
print_path_field("汉化 current", &self.localized_current_path);
print_optional_path_field(
"汉化 published",
self.localized_published_version_path.as_ref(),
);
print_path_field("active release", &self.active_resource_root);
print_path_field("current", &self.current_path);
print_path_field("version state", &self.version_state_path);
print_optional_path_field("staging", self.staging_path.as_ref());
print_optional_path_field("published", self.published_version_path.as_ref());
print_path_field("snapshot", &self.snapshot_path);
print_path_field("manifest", &self.download_manifest);
print_optional_path_field("资源变更集", self.resource_change_set_path.as_ref());
print_optional_path_field("Crowdin handoff", self.crowdin_handoff_path.as_ref());
print_optional_path_field("解析缓存", self.parse_cache_path.as_ref());
print_optional_path_field("TextUnit 任务队列", self.textunit_task_queue_path.as_ref());
print_optional_path_field(
"Crowdin TextUnit 队列",
self.crowdin_textunit_queue_path.as_ref(),
);
print_optional_path_field("写入 snapshot", self.snapshot_written.as_ref());
print_optional_path_field(
"启动器引导产物",
self.launcher_bootstrap_artifact_path.as_ref(),
);
print_optional_path_field("bootstrap cache", self.bootstrap_cache_path.as_ref());
print_optional_field("bootstrap 命中", self.bootstrap_cache_hit.map(format_bool));
print_optional_field("计划 URL 数", self.download_url_count);
print_optional_field("资源数", self.resource_count);
print_field("已下载", self.downloaded_count);
print_field("已续传", self.resumed_count);
print_field("当前 manifest 复用", self.skipped_count);
print_field("历史 release 复用", self.release_reused_count);
print_field("CAS 复用", self.cas_reused_count);
print_field("复用量", format_bytes(self.reused_bytes));
print_field("传输量", format_bytes(self.transferred_bytes));
print_field("复用诊断", self.reuse_warnings.len());
for warning in &self.reuse_warnings {
println!(
" - 复用回退 [{}] {} {}",
warning.source, warning.url, warning.message
);
}
print_field("最终大小", format_bytes(self.final_bytes));
print_field("本地校验通过", self.local_manifest_verified_count);
print_field("需修复", self.local_manifest_repair_needed_count);
print_field("官方 hash 校验", self.official_seed_hash_verified_count);
print_verification_summary(&self.verification_summary);
if let Some(summary) = self.resource_change_summary.as_ref() {
print_field("新增资源", summary.added_count);
print_field("变更资源", summary.modified_count);
print_field("删除资源", summary.removed_count);
print_field("解析候选", summary.parse_candidate_count);
print_field("Crowdin 候选", summary.translation_candidate_count);
}
if let Some(summary) = self.parse_summary.as_ref() {
print_field("解析缓存条目", summary.cache_entry_count);
print_field("解析成功 bundle", summary.parsed_bundle_count);
print_field("解析复用", summary.skipped_unchanged_count);
print_field("解析不支持", summary.unsupported_count);
print_field("解析失败", summary.failed_count);
print_field("TextAsset", summary.text_asset_count);
print_field("TextUnit", summary.text_unit_count);
print_field("二进制 TextAsset", summary.skipped_binary_text_asset_count);
print_field("TextUnit 诊断", summary.text_unit_error_count);
}
if let Some(summary) = self.textunit_task_summary.as_ref() {
print_field("TextUnit 资源候选", summary.resource_candidate_count);
print_field("TextUnit 解析条目", summary.parse_entry_count);
print_field("TextUnit 任务", summary.queued_task_count);
print_field("增量 TextUnit", summary.text_unit_count);
print_field("TextUnit 无解析", summary.skipped_no_parse_entry_count);
print_field("TextUnit 无文本", summary.skipped_no_text_unit_count);
print_field("TextUnit 解析失败", summary.skipped_parse_failed_count);
print_field("TextUnit 不支持", summary.skipped_unsupported_count);
}
print_field("catalog marker", self.addressables_marker_checked_count);
if !self.unavailable_endpoints.is_empty() {
let unavailable = self
.unavailable_endpoints
.iter()
.map(|endpoint| {
format!(
"{}{} kind={} http={} {}",
endpoint_kind_label_for_human(endpoint.kind),
endpoint
.platform
.map(|platform| format!(" ({})", platform_label(platform)))
.unwrap_or_default(),
endpoint.error_kind,
endpoint
.http_status
.map(|status| status.to_string())
.unwrap_or_else(|| "none".to_string()),
endpoint.url
)
})
.collect::<Vec<_>>();
print_list("不可用官方 endpoint", &unavailable, 8);
}
print_list("变更 endpoint", &self.changed_endpoint_urls, 8);
print_list("计划 URL", &self.download_urls, 8);
Ok(())
}
}
impl<T> HumanReport for CommandReport<T>
where
T: Serialize + HumanReport,
{
fn print_human(&self) -> anyhow::Result<()> {
print_title(self.message);
print_field("命令", self.command);
print_field("状态", self.status);
self.data.print_human()
}
}
impl HumanReport for PatchApplyReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title(self.message);
print_field("命令", self.command);
print_field("状态", self.status);
print_field("Patch 类型", self.kind.as_str());
print_path_field("源文件", &self.source_path);
print_path_field("Patch 文件", &self.patch_path);
print_path_field("目标文件", &self.target_path);
print_field("源字节", self.source_size);
print_field("Patch 字节", self.patch_size);
print_field("目标字节", self.target_size);
print_field("源 BLAKE3", &self.source_blake3);
print_field("Patch BLAKE3", &self.patch_blake3);
print_field("目标 BLAKE3", &self.target_blake3);
Ok(())
}
}
impl HumanReport for UnityFsPatchReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title(self.message);
print_field("命令", self.command);
print_field("状态", self.status);
print_path_field("源 bundle", &self.bundle_path);
print_field("Serialized 文件", &self.serialized_file_path);
print_field("Path ID", self.path_id);
print_optional_field("字段路径", self.field_path.as_deref());
print_path_field("目标 bundle", &self.target_path);
print_field("源字节", self.source_size);
print_field("替换字节", self.replacement_size);
print_field("目标字节", self.target_size);
print_field("源 BLAKE3", &self.source_blake3);
print_field("替换 BLAKE3", &self.replacement_blake3);
print_field("目标 BLAKE3", &self.target_blake3);
Ok(())
}
}
impl HumanReport for DaemonStartReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title(self.message);
print_field("状态", self.status);
print_field("PID", self.pid);
print_path_field("资源目录", &self.resource_output_root);
print_path_field("汉化目录", &self.localized_output_root);
print_path_field("状态目录", &self.state_dir);
print_path_field("socket", &self.socket_path);
print_path_field("日志", &self.log_path);
print_path_field("结构化日志", &self.structured_log_path);
Ok(())
}
}
impl HumanReport for DaemonStatusReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title("后台状态");
print_field("状态", self.status);
print_field("消息", self.message);
print_field("运行中", format_bool(self.running));
print_optional_field("PID", self.pid);
print_optional_field("后台状态", self.daemon_state.as_deref());
print_field("RPC 可用", format_bool(self.rpc_available));
print_field("失效 PID", format_bool(self.stale_pid_file));
print_field("失效 socket", format_bool(self.stale_socket));
print_optional_field("上次同步", self.last_update_status.as_deref());
print_optional_field("最后成功时间", self.last_success_unix_seconds);
print_optional_field("上次错误", self.last_error.as_deref());
print_optional_field("下次重试秒数", self.next_retry_seconds);
print_optional_field("下次检查时间", self.next_check_unix_seconds);
print_optional_field("当前阶段", self.current_stage.as_deref());
print_optional_field("当前消息", self.current_message.as_deref());
if let Some(progress) = self.download_progress.as_ref() {
print_field("下载进度", format_daemon_download_progress(progress));
}
if let Some(version_state) = self.version_state.as_ref() {
print_daemon_version_state_summary(version_state);
}
print_optional_path_field("资源目录", self.resource_output_root.as_ref());
print_optional_path_field("汉化目录", self.localized_output_root.as_ref());
print_optional_path_field("版本状态", self.version_state_path.as_ref());
print_path_field("状态目录", &self.state_dir);
print_path_field("socket", &self.socket_path);
print_optional_path_field("日志", self.log_path.as_ref());
print_optional_path_field("结构化日志", self.structured_log_path.as_ref());
let rotated = self
.rotated_structured_log_paths
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>();
print_list("轮转日志", &rotated, 5);
Ok(())
}
}
impl HumanReport for DaemonStopReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title(self.message);
print_field("状态", self.status);
print_field("已停止", format_bool(self.stopped));
print_optional_field("PID", self.pid);
print_path_field("状态目录", &self.state_dir);
print_path_field("socket", &self.socket_path);
Ok(())
}
}
impl HumanReport for DaemonControlReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title(self.message);
print_field("命令", self.command);
print_field("状态", self.status);
print_field("策略", self.strategy);
print_optional_field("旧 PID", self.previous_pid);
print_field("PID", self.pid);
print_path_field("资源目录", &self.resource_output_root);
print_path_field("状态目录", &self.state_dir);
print_path_field("socket", &self.socket_path);
print_path_field("日志", &self.log_path);
Ok(())
}
}
impl HumanReport for VerifyCommandReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title(self.message);
print_field("命令", self.command);
print_field("状态", self.status);
print_field("健康", format_bool(self.healthy));
print_field("远端状态", &self.remote_update_status);
print_path_field("校验资源目录", &self.verified_resource_root);
print_optional_field("计划 URL 数", self.planned_url_count);
print_field("计划异常数", self.expected_plan_failure_count);
print_field("本地 manifest 项", self.local_manifest_entry_count);
print_field("本地校验通过", self.local_manifest_verified_count);
print_field("本地失败数", self.local_manifest_failure_count);
print_field("官方 hash 对", self.official_hash_pair_count);
print_field("官方 hash 通过", self.official_hash_verified_count);
print_verification_summary(&self.verification_summary);
if !self.official_hash_errors.is_empty() {
print_list("官方 hash 错误", &self.official_hash_errors, 8);
}
if !self.failures.is_empty() {
println!(" 失败项:");
for item in self.failures.iter().take(12) {
println!(" - {} -> {}", item.status, item.destination.display());
}
if self.failures.len() > 12 {
println!(" ... 还有 {}", self.failures.len() - 12);
}
}
Ok(())
}
}
impl HumanReport for LogsReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title(self.message);
print_field("状态", self.status);
print_path_field("日志", &self.log_path);
print_field("存在", format_bool(self.exists));
print_field("为空", format_bool(self.empty));
print_field("字节", self.bytes);
print_field("总行数", self.total_lines);
print_field("返回行数", self.returned_lines);
if !self.content.is_empty() {
println!();
println!("{}", self.content);
}
Ok(())
}
}
impl HumanReport for DoctorReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title(self.message);
print_field("命令", self.command);
print_field("状态", self.status);
print_field("健康", format_bool(self.healthy));
println!(" 检查:");
for check in &self.checks {
println!(
" [{}] {} - {}",
if check.ok { "OK" } else { "FAIL" },
check.name,
check.message
);
}
Ok(())
}
}
impl HumanReport for DoctorCasReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title(self.message);
print_field("命令", self.command);
print_field("状态", self.status);
print_field("健康", format_bool(self.healthy));
print_path_field("CAS 目录", &self.cas_root);
print_path_field("对象目录", &self.objects_dir);
print_path_field("元数据库", &self.metadata_db_path);
print_field("对象数", self.object_count);
print_field("总字节", self.total_size);
print_field("无效对象文件", self.invalid_object_count);
println!(" 检查:");
for check in &self.checks {
println!(
" [{}] {} - {}",
if check.ok { "OK" } else { "FAIL" },
check.name,
check.message
);
}
Ok(())
}
}
impl HumanReport for CleanStableReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title(self.message);
print_field("命令", self.command);
print_field("状态", self.status);
print_path_field("资源目录", &self.output_root);
print_path_field("状态目录", &self.state_dir);
if !self.removed_paths.is_empty() {
println!(" 已清理:");
for path in &self.removed_paths {
println!(" - {}", path.display());
}
}
if !self.skipped_paths.is_empty() {
println!(" 已跳过:");
for path in &self.skipped_paths {
println!(" - {}", path.display());
}
}
Ok(())
}
}
impl HumanReport for DaemonRpcAck {
fn print_human(&self) -> anyhow::Result<()> {
print_title(self.message);
print_field("命令", self.command);
print_field("状态", self.status);
print_optional_field("force", self.force.map(format_bool));
print_path_field("状态目录", &self.state_dir);
print_path_field("socket", &self.socket_path);
Ok(())
}
}
@@ -1,3 +1,4 @@
use super::report_output::print_json_value;
use super::*;
const SCHEDULES_FILE_NAME: &str = "bat-schedules.json";
+6 -2
View File
@@ -475,10 +475,14 @@ impl TaskStore {
Ok(bytes) => {
if let Err(error) = write_file_atomic(path, &bytes, PRIVATE_FILE_MODE, "任务历史")
{
eprintln!("[daemon] 任务历史落盘失败:{error}");
super::terminal_output::print_daemon_error(format!(
"任务历史落盘失败:{error}"
));
}
}
Err(error) => eprintln!("[daemon] 任务历史序列化失败:{error}"),
Err(error) => {
super::terminal_output::print_daemon_error(format!("任务历史序列化失败:{error}"))
}
}
}
@@ -0,0 +1,527 @@
use super::*;
pub(super) const STARTUP_BANNER: &str = r#"
=====================================================================================
____ _ _ _ _ _____ _ _ _ _
| __ )| |_ _ ___ / \ _ __ ___| |__ (_)_ _____|_ _|__ ___ | | | _(_) |_
| _ \| | | | |/ _ \/ _ \ | '__/ __| '_ \| \ \ / / _ \ | |/ _ \ / _ \| | |/ / | __|
| |_) | | |_| | __/ ___ \| | | (__| | | | |\ V / __/ | | (_) | (_) | | <| | |_
|____/|_|\__,_|\__/_/ \_\_| \___|_| |_|_|\_/ \___/ |_|\___/ \___/|_|_|\_\_|\__|
BlueArchiveToolkit
Official Resource Sync
=====================================================================================
"#;
#[derive(Debug, Serialize)]
struct ErrorReport {
status: &'static str,
exit_code: i32,
error: String,
#[serde(skip_serializing_if = "Option::is_none")]
next_retry_seconds: Option<u64>,
}
pub(super) fn print_fatal_error(error: String, exit_code: i32) -> ! {
let payload = ErrorReport {
status: "error",
exit_code,
error,
next_retry_seconds: None,
};
eprintln!(
"{}",
serde_json::to_string_pretty(&payload)
.unwrap_or_else(|_| "{\"status\":\"error\"}".to_string())
);
std::process::exit(exit_code);
}
pub(super) fn print_error_report(
error: impl Into<String>,
next_retry_seconds: Option<u64>,
) -> anyhow::Result<()> {
let payload = ErrorReport {
status: "error",
exit_code: EXIT_ERROR,
error: error.into(),
next_retry_seconds,
};
eprintln!("{}", serde_json::to_string_pretty(&payload)?);
Ok(())
}
pub(super) fn print_repeated_workflow_wait(
command_name: &str,
interval: Duration,
completed_runs: usize,
) {
eprintln!(
"{command_name} 下一轮将在 {} 后执行(已完成 {} 轮)",
format_duration(interval),
completed_runs
);
}
pub(super) fn print_daemon_error(message: impl std::fmt::Display) {
eprintln!("[daemon] {message}");
}
pub(super) fn print_startup_banner() {
eprintln!("{STARTUP_BANNER}");
}
pub(super) fn print_env_template_created(path: &Path) {
eprintln!(
"已生成配置模板 {}(编辑其中的 BAT_* 配置后,直接运行 `bat` 即可按 .env 启动)",
path.display()
);
}
pub(super) fn print_env_template_warning(path: &Path, error: impl std::fmt::Display) {
eprintln!("警告:生成 .env 配置模板失败 {}{error}", path.display());
}
pub(super) fn print_env_read_warning(path: &Path, error: impl std::fmt::Display) {
eprintln!("警告:读取 .env 失败 {}{error}", path.display());
}
pub(super) fn print_env_parse_warning(line_number: usize, raw_line: &str) {
eprintln!(
"警告:.env 第 {} 行无法解析,已忽略:{raw_line}",
line_number
);
}
#[derive(Debug, Clone)]
pub(super) struct ProgressLogger {
enabled: bool,
started_at: Instant,
structured: Option<RotatingStructuredLogger>,
}
impl ProgressLogger {
pub(super) fn new(enabled: bool) -> Self {
Self {
enabled,
started_at: Instant::now(),
structured: None,
}
}
pub(super) fn attach_structured_log(&mut self, path: PathBuf) {
self.structured = Some(RotatingStructuredLogger::new(
path,
STRUCTURED_LOG_MAX_BYTES,
STRUCTURED_LOG_ROTATE_KEEP,
));
}
pub(super) fn log(&mut self, event: OfficialUpdateProgress) {
self.log_event(&event);
}
pub(super) fn log_text(&mut self, stage: &str, message: impl AsRef<str>) {
self.log_text_inner(stage, message.as_ref());
}
fn log_event(&mut self, event: &OfficialUpdateProgress) {
if self.enabled {
print_progress_line(
self.started_at.elapsed(),
event.stage,
event.message.as_str(),
);
}
if let Some(structured) = self.structured.as_mut() {
let _ = structured.write_event(self.started_at.elapsed(), event);
}
}
fn log_text_inner(&mut self, stage: &str, message: &str) {
if !self.enabled {
if let Some(structured) = self.structured.as_mut() {
let event = OfficialUpdateProgress::new(stage_to_static(stage), message);
let _ = structured.write_event(self.started_at.elapsed(), &event);
}
return;
}
print_progress_line(self.started_at.elapsed(), stage, message);
if let Some(structured) = self.structured.as_mut() {
let event = OfficialUpdateProgress::new(stage_to_static(stage), message);
let _ = structured.write_event(self.started_at.elapsed(), &event);
}
}
}
fn print_progress_line(elapsed: Duration, stage: &str, message: &str) {
eprintln!(
"[+{} 信息] [{}] {}",
format_duration(elapsed),
localized_stage(stage),
message
);
}
#[derive(Debug, Clone)]
pub(super) struct RotatingStructuredLogger {
path: PathBuf,
max_bytes: u64,
keep: usize,
}
impl RotatingStructuredLogger {
pub(super) fn new(path: PathBuf, max_bytes: u64, keep: usize) -> Self {
Self {
path,
max_bytes,
keep,
}
}
pub(super) fn write_event(
&mut self,
elapsed: Duration,
event: &OfficialUpdateProgress,
) -> anyhow::Result<()> {
let payload = serde_json::json!({
"timestamp_unix_seconds": unix_seconds_now(),
"elapsed_ms": elapsed.as_millis(),
"level": "info",
"stage": event.stage,
"stage_label": localized_stage(event.stage),
"status_code": event.status_code.as_str(),
"status_phase": event.status_code.phase(),
"message": event.message.as_str(),
"download": event.download_index.map(|index| serde_json::json!({
"index": index,
"total": event.download_total.unwrap_or(index),
"url": event.download_url.as_deref(),
"status": event.download_status.as_deref(),
"bytes": event.download_bytes,
"transferred_bytes": event.download_transferred_bytes,
"failure_kind": event.download_failure_kind.as_deref(),
"failure_http_status": event.download_failure_http_status,
"failure_retryable": event.download_failure_retryable,
"failure_attempts": event.download_failure_attempts,
"quarantined": event.download_quarantined,
"verification": event.download_verification,
"official_hash": event.official_hash_verification,
})),
});
let mut line = serde_json::to_vec(&payload)?;
line.push(b'\n');
self.rotate_if_needed(line.len() as u64)?;
let mut file = open_append_file(&self.path, PRIVATE_FILE_MODE, "结构化日志")
.map_err(anyhow::Error::msg)?;
file.write_all(&line)?;
file.flush()?;
Ok(())
}
fn rotate_if_needed(&self, incoming_bytes: u64) -> anyhow::Result<()> {
let current_len = match fs::symlink_metadata(&self.path) {
Ok(metadata) if metadata.file_type().is_symlink() => {
return Err(anyhow::anyhow!(
"结构化日志不能是 symlink{}",
self.path.display()
))
}
Ok(metadata) if metadata.is_file() => metadata.len(),
Ok(_) => {
return Err(anyhow::anyhow!(
"结构化日志已存在但不是普通文件:{}",
self.path.display()
))
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => 0,
Err(error) => return Err(error.into()),
};
if current_len.saturating_add(incoming_bytes) <= self.max_bytes {
return Ok(());
}
for index in (1..=self.keep).rev() {
let from = if index == 1 {
self.path.clone()
} else {
rotated_structured_log_path(&self.path, index - 1)
};
let to = rotated_structured_log_path(&self.path, index);
if !path_exists_no_follow(&from)? {
continue;
}
if path_exists_no_follow(&to)? {
fs::remove_file(&to)?;
}
fs::rename(&from, &to)?;
}
Ok(())
}
}
fn stage_to_static(stage: &str) -> &'static str {
match stage {
"start" => "start",
"lock" => "lock",
"bootstrap" => "bootstrap",
"launcher" => "launcher",
"bootstrap-cache" => "bootstrap-cache",
"game-main-config" => "game-main-config",
"metadata" => "metadata",
"server-info" => "server-info",
"discovery" => "discovery",
"markers" => "markers",
"marker" => "marker",
"catalog" => "catalog",
"local-state" => "local-state",
"audit" => "audit",
"decision" => "decision",
"plan" => "plan",
"download" => "download",
"snapshot" => "snapshot",
"publish" => "publish",
"parse" => "parse",
"finish" => "finish",
"watch" => "watch",
"daemon" => "daemon",
"dry-run" => "dry-run",
_ => "log",
}
}
fn localized_stage(stage: &str) -> &str {
match stage {
"start" => "启动",
"lock" => "",
"bootstrap" => "启动发现",
"launcher" => "启动器",
"bootstrap-cache" => "启动缓存",
"game-main-config" => "游戏配置",
"metadata" => "元数据",
"server-info" => "服务器信息",
"discovery" => "发现",
"markers" => "标记",
"marker" => "标记",
"snapshot" => "快照",
"decision" => "决策",
"plan" => "计划",
"catalog" => "目录",
"inventory" => "清单",
"local-state" => "本地状态",
"audit" => "审计",
"dry-run" => "试运行",
"download" => "下载",
"publish" => "发布",
"parse" => "解析",
"resource" => "资源",
"finish" => "完成",
"watch" => "常驻",
"daemon" => "后台",
_ => stage,
}
}
pub(super) fn print_usage(binary: &str) {
let usage = format!(
r#"BlueArchiveToolkit official resource sync
Usage:
{binary} [OPTIONS]
{binary} <COMMAND> [OPTIONS]
Commands:
res pull Pull official resources once or repeatedly
res schedule Manage resource pull schedules (CLI/RPC/dashboard)
parse run Parse current official release
parse clear-cache Clear regenerable parse and translation queue files
parse repack Repack a UnityFS bundle from a JSON spec
parse schedule Manage parse schedules
i18n run Refresh offline translation work
i18n export Export an editable translation workbench
i18n set Update one translation workbench entry
i18n get Show one translation workbench entry
i18n unset Clear one translated workbench entry
i18n validate Validate workbench against the current official release
i18n proofread Mark localized workflow as manual proofreading
i18n worker run Run translation provider worker once or repeatedly
i18n tasks / i18n task list / i18n task status Query current offline TextUnit translation task status
i18n handoff Query current translation handoff
i18n status Show localized release status for current official release
i18n task update Update one provider worker task status
i18n publish Publish a localized release from a workbench or worker results
i18n rollback Roll back the current localized release
i18n schedule Manage translation schedules
refresh Run one update check, or ask a live daemon to refresh
verify Verify remote plan, local manifest, and official seed hashes
repair Redownload resources that fail local verification
parse-status Show current official parse-cache status
parse-text-units Query current official TextUnit detail index
parse-errors Query current official parse/extraction diagnostics
translation-tasks Query current offline TextUnit translation task status
translation-handoff Query current translation job/unit/provider handoff
localized-status Show localized release status for current official release
localized-rollback Roll back the current localized release
resource-index Query CAS + ResourceRepository index
patch-apply Apply a Binary/JSON/Text patch file
unityfs-patch-text-asset Patch one UnityFS TextAsset object
unityfs-patch-string-field Patch one UnityFS TypeTree string field
unityfs-patch-field Patch one UnityFS TypeTree field with semantic JSON
status Show daemon state
stop Stop daemon
restart Restart daemon, reusing saved args unless explicit args are passed
reload Ask daemon to rediscover metadata and force refresh
logs Show daemon log tail
doctor Run runtime diagnostics
doctor cas Inspect local CAS storage
clean-stable Remove .part/.tmp/stale lock, pid, and socket files
Examples:
{binary} --auto-discover --dry-run
{binary} --auto-discover --watch
{binary} --auto-discover --daemon
{binary} res pull --auto-discover --run-count 3 --interval 1h
{binary} parse run --force --resource-root /tmp/bat-release
{binary} parse schedule list --state-dir /tmp/bat-schedule
{binary} i18n export --translation-file /tmp/bat-workbench.json
{binary} i18n get --translation-file /tmp/bat-workbench.json --translation-id unit-1
{binary} i18n unset --translation-file /tmp/bat-workbench.json --translation-id unit-1
{binary} i18n proofread --json
{binary} i18n worker run --provider mock --worker-concurrency 8 --run-count 2 --interval 30s
{binary} i18n tasks --json
{binary} i18n handoff --json
{binary} i18n status --json
{binary} i18n publish --translation-file /tmp/bat-workbench.json --force
{binary} i18n publish --from-worker --localized-release-id release-manual-1
{binary} i18n rollback --localized-release-id release-manual-1
{binary} status
{binary} refresh --force --json
Discovery:
--auto-discover Discover app-version, server-info, connection-group
--server-info-url <URL> Use an official server-info URL
--server-info-file <NAME> Use an official server-info file name
--server-info-path <PATH> Use a local server-info JSON file
--app-version <VERSION> Override app version
--connection-group <NAME> Override connection group
--launcher-version <VERSION> Launcher metadata API version (default: 1.7.2)
Sync:
--platforms <LIST> Platforms, e.g. Windows,Android
--output <DIR> Official resource publish root (default: ./bat-resources)
--localized-output <DIR> Localized output root (default: ./bat-localized)
--import-repository Import verified release into CAS + ResourceRepository
--no-import-repository Disable CAS + ResourceRepository import
--import-cas-root <DIR> CAS root for official release imports
--import-resource-db <PATH> SQLite ResourceRepository path
--snapshot <PATH> Override snapshot path (default: <output>/current/official-sync-snapshot.json)
--curl <PATH> curl executable (default: curl)
--download-concurrency <N> Bounded parallel downloads (default: 8, range 1..=256)
--proxy <URL|auto|none> curl proxy override (default: auto from env)
--no-proxy Force direct curl connections
--unzip <PATH> unzip executable (default: unzip)
--dry-run Do not write sync state
--plan Include planned URLs in dry-run
--force Force download/refresh
--audit-local | --no-audit-local Enable/disable local manifest audit
--repair | --no-repair Enable/disable automatic repair
--run-count <N> Run pull/parse/translate/publish N times
--once Explicitly select one run
--resource-root <DIR> Use an explicit published official release root
--translation-file <PATH> / --workbench <PATH> Translation workbench JSON file
--translation-id <ID> TextUnit ID for i18n set
--translated-text <TEXT> Inline translation for i18n set
--translated-file <PATH> UTF-8 translation file for i18n set
--from-worker Build publish input from completed provider worker results
--failure-reason <TEXT> Provider failure reason for i18n task update
--provider-run-id <ID> Provider run ID for i18n task update
--translation-provider <NAME> / --provider <NAME> Provider for i18n worker run (mock/crowdin)
--translation-fixture <PATH> Mock/provider fixture for i18n worker run
--worker-concurrency <N> Translation worker concurrency (default: 8, range 1..=256)
--worker-max-attempts <N> Maximum claims per translation task
--worker-lease-seconds <N> Lease seconds for one claimed task
--worker-retry-backoff <DURATION> Retry backoff after retryable failure
--worker-retry-backoff-seconds <N> Retry backoff seconds
--worker-max-tasks <N> Maximum tasks claimed in one worker run
--worker-id <ID> Worker ID prefix for lease diagnostics
--localized-release-id <ID> Explicit localized publication ID
--repack-spec <PATH> UnityFS batch repack JSON spec
Read-only queries:
--offset <N> Query offset for resource-index/parse-text-units/parse-errors/translation-tasks
--limit <N> Query limit for resource-index/parse-text-units/parse-errors/translation-tasks (1..=1000)
--task-id <ID> Filter translation-tasks by stable task ID
--resource-type <TYPE> asset_bundle, manifest, table_bundle, text_asset, media, other
--hash <HASH> Filter resource-index by full CAS hash
--path-pattern <GLOB> Filter resource-index or parse detail by path pattern
--release-id <ID> Filter resource-index or translation-tasks by official release ID
--platform <NAME> Filter resource-index by metadata platform
--destination <PATH> Filter resource-index, parse detail, or translation-tasks by official destination
--bundle-path <PATH> Filter resource-index by metadata bundle path
--archive-entry <PATH> Filter resource-index, parse detail, or translation-tasks by ZIP/archive entry
--task-status <STATUS> Filter translation-tasks by task status
--worker-status <STATUS> Filter translation-tasks by provider worker status
--parse-status <STATUS> Filter resource-index or translation-tasks by parse status
--path-id <ID> Filter parse detail by Unity object path ID
--class-id <ID> Filter parse detail by Unity class ID
--field-path <PATH> Filter parse detail, or TypeTree field path after UnityFS field patch commands
--format <NAME> Filter resource-index, parse text units, or translation-tasks by payload format
--has-reason | --no-reason Filter translation-tasks by diagnostic reason presence
--has-failure-reason | --no-failure-reason Filter translation-tasks by provider failure reason
Write patch:
--patch-kind <binary|json|text> Patch type for patch-apply
--source-file <PATH> Source file for patch-apply
--patch-file <PATH> Patch JSON file for patch-apply
--bundle-file <PATH> Source UnityFS bundle file
--serialized-file <PATH> Serialized file path inside UnityFS
--object-path-id <ID> Unity object path ID for UnityFS patch
--string-field-path <PATH> Deprecated alias for UnityFS TypeTree field path
--replacement-file <PATH> Replacement bytes or UTF-8 string file
--replacement-text <TEXT> Inline replacement text for string-field patch
--replacement-json <JSON> Semantic replacement, e.g. signed/enum/bit_field JSON
--expected-name <NAME> Expected TextAsset name
--expected-value <TEXT> Expected source string value
--expected-json <JSON> Optional expected semantic source value
--target-file <PATH> Target output file written atomically
Daemon:
--watch Run in foreground loop
--daemon Start detached watch process
--state-dir <DIR> Daemon state dir (default: /tmp/bat-pid)
--interval <DURATION> Normal check interval (default: 1h)
--error-retry <DURATION> Retry interval after error (default: 60s)
--quiet-up-to-date Suppress clean up-to-date reports
--no-quiet-up-to-date Always print reports
--tail <N> Log lines for logs command (default: 200)
--schedule-id <ID> / --id <ID> Schedule identifier
--schedule-action <ACTION> / --action <ACTION> Schedule action (pull/run/repack/publish)
--schedule-at-unix <SECONDS> First execution time
--schedule-delay <DURATION> Delay first execution from now
--schedule-every <DURATION> Period between executions
--schedule-count <N> Bounded execution count
--schedule-max-runs <N> Maximum plans executed by one schedule run
--schedule-arg <ARG> Argument passed to scheduled child command
--schedule-clear-args Clear args during schedule update
--schedule-clear-every Convert a periodic plan to one-shot
--schedule-enabled/--schedule-disabled Enable/disable a schedule
Output:
--human Human-readable output (default)
--json Stable JSON output for scripts
--progress | --no-progress Enable/disable stderr progress logs
--banner | --no-banner Enable/disable startup banner
-h, --help Show this help
Defaults:
platforms: Windows,Android
official resource output: ./bat-resources (current -> versions/<id>, .staging/<id>)
localized output: ./bat-localized (separate patch/export target)
daemon state: /tmp/bat-pid (bat.sock, bat.pid, bat-status.json, bat-daemon.log, bat-events.jsonl)
forced refresh: {DAILY_FORCED_REFRESH_LABEL}
"#,
binary = binary,
DAILY_FORCED_REFRESH_LABEL = DAILY_FORCED_REFRESH_LABEL,
);
eprint!("{usage}");
}
@@ -1,3 +1,4 @@
use super::report_output::{print_json_value, print_report};
use super::*;
pub(super) fn run_parse_once(options: &CliOptions) -> anyhow::Result<()> {