use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use clap::{Args, Parser, Subcommand};
use one_saves::extension::{Civil, SHOWN_BYTES, SHOWN_CHARS};
use one_saves::{Bundle, Game, Name, Part, PartKind, Payload, Source};
use one_saves_convert::{Format, Profile, card, detect, raw};
#[derive(Parser)]
#[command(name = "1saves", version, about, long_about = None)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Convert(Box<Convert>),
Extract(Extract),
Inspect(FileArg),
Verify(FileArg),
Hash(FileArg),
Profiles,
}
#[derive(Args)]
struct FileArg {
file: PathBuf,
}
#[derive(Args)]
struct Convert {
input: PathBuf,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long, value_name = "NAME")]
from: Option<String>,
#[arg(long, value_name = "SLUG")]
system: Option<String>,
#[arg(long, value_name = "SLUG")]
role: Option<String>,
#[arg(long, value_name = "VERSION")]
app_version: Option<String>,
#[arg(long, value_name = "NAME")]
core: Option<String>,
#[arg(long, value_name = "VERSION")]
core_version: Option<String>,
#[arg(long, value_name = "NAME")]
platform: Option<String>,
#[arg(long, value_name = "VERSION")]
platform_version: Option<String>,
#[arg(long)]
description: Option<String>,
#[cfg(feature = "rom")]
#[arg(long, value_name = "FILE")]
rom: Option<PathBuf>,
#[cfg(feature = "dat")]
#[arg(long, value_name = "FILE")]
dat: Option<PathBuf>,
#[arg(long, value_name = "FILE")]
rtc_file: Option<PathBuf>,
#[arg(long, value_name = "EPOCH")]
rtc_captured_at: Option<i64>,
#[arg(long)]
keep_rtc_inline: bool,
#[arg(long)]
force: bool,
}
#[derive(Args)]
struct Extract {
input: PathBuf,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long, value_name = "NAME")]
to: Option<String>,
#[arg(long, value_name = "ID")]
part: Option<u64>,
#[arg(long, value_name = "FILE")]
rtc_file: Option<PathBuf>,
#[arg(long)]
force: bool,
}
fn main() -> ExitCode {
let cli = Cli::parse();
match run(cli) {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("1saves: {error}");
let mut source = std::error::Error::source(&*error);
while let Some(inner) = source {
eprintln!(" caused by: {inner}");
source = inner.source();
}
ExitCode::FAILURE
}
}
}
type Fallible = Result<(), Box<dyn std::error::Error>>;
fn run(cli: Cli) -> Fallible {
match cli.command {
Command::Convert(args) => convert(*args),
Command::Extract(args) => extract(args),
Command::Inspect(args) => inspect(&args.file),
Command::Verify(args) => verify(&args.file),
Command::Hash(args) => hash(&args.file),
Command::Profiles => {
profiles();
Ok(())
}
}
}
fn extension_of(path: &Path) -> String {
path.extension().and_then(|e| e.to_str()).unwrap_or_default().to_ascii_lowercase()
}
fn write_out(path: &Path, bytes: &[u8], force: bool) -> Fallible {
if path.exists() && !force {
return Err(format!("{} already exists; pass --force to overwrite", path.display()).into());
}
std::fs::write(path, bytes)?;
Ok(())
}
#[cfg(feature = "archive")]
fn unwrap_archive(
input: &std::path::Path,
bytes: Vec<u8>,
) -> Result<(Vec<u8>, String), Box<dyn std::error::Error>> {
if !one_saves_convert::archive::is_archive(&bytes) {
return Ok((bytes, extension_of(input)));
}
let unpacked = one_saves_convert::archive::unpack(&bytes)?;
if unpacked.assembled {
eprintln!("{}: built a card from {}", input.display(), unpacked.source);
}
Ok((unpacked.bytes, unpacked.extension))
}
type Sidecar = (std::path::PathBuf, Vec<u8>, Option<i64>);
fn sidecar_clock(args: &Convert) -> Result<Option<Sidecar>, Box<dyn std::error::Error>> {
let path = args.rtc_file.clone().or_else(|| {
let beside = args.input.with_extension("rtc");
beside.exists().then_some(beside)
});
let Some(path) = path else {
if args.rtc_captured_at.is_some() {
return Err("--rtc-captured-at was given, but there is no sidecar clock to apply it to".into());
}
return Ok(None);
};
let bytes = std::fs::read(&path)?;
if bytes.len() != one_saves_convert::rtc::GAMBATTE_RTC_LEN {
if args.rtc_captured_at.is_some() {
return Err(format!(
"--rtc-captured-at was given, but {} records its own instant",
path.display()
)
.into());
}
return Ok(Some((path, bytes, None)));
}
let captured_at = if let Some(stated) = args.rtc_captured_at {
stated
} else {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|e| format!("the system clock is before 1970: {e}"))?
.as_secs();
i64::try_from(now)?
};
Ok(Some((path, bytes, Some(captured_at))))
}
fn convert(args: Convert) -> Fallible {
#[cfg(feature = "archive")]
let (bytes, extension) = unwrap_archive(&args.input, std::fs::read(&args.input)?)?;
#[cfg(not(feature = "archive"))]
let (bytes, extension) = (std::fs::read(&args.input)?, extension_of(&args.input));
let profile = args.from.as_deref().and_then(one_saves_convert::profile);
let named_format = args.from.as_deref().and_then(Format::from_name);
let format = match named_format {
Some(format) => format,
None => detect::detect(&bytes, &extension)?,
};
if format == Format::Bundle {
return Err("this file is already a bundle".into());
}
#[cfg(feature = "rom")]
let Identified { mut game, system: rom_system, clock: rom_clock } = identify(&args)?;
#[cfg(not(feature = "rom"))]
let Identified { mut game, system: rom_system, clock: rom_clock } = Identified::default();
let sidecar = sidecar_clock(&args)?;
let sidecar_path = sidecar.as_ref().map(|(path, _, at)| (path.clone(), at.is_some()));
let sidecar = sidecar.map(|(_, bytes, at)| (bytes, at.unwrap_or_default()));
let source = source_of(&args, profile.as_ref())?;
let core_system = source
.as_ref()
.and_then(|source| source.core.as_ref())
.and_then(|core| one_saves_registry::core(core.as_str()))
.and_then(|core| match core.systems {
[only] => Some(*only),
_ => None,
});
let system = args
.system
.clone()
.or_else(|| rom_system.map(ToOwned::to_owned))
.or_else(|| profile.as_ref().and_then(Profile::only_system).map(ToOwned::to_owned))
.or_else(|| core_system.map(ToOwned::to_owned));
let mut bundle = if format == Format::Raw {
let options = raw::RawOptions {
system,
role: args.role.clone(),
source,
game: game.clone(),
description: args.description.clone(),
split_rtc: !args.keep_rtc_inline,
rtc_sidecar: sidecar,
clock_chip: rom_clock.map(ToOwned::to_owned),
..raw::RawOptions::default()
};
raw::wrap(&bytes, &options)?
} else {
let options = card::CardOptions {
role: args.role.as_deref().map(|role| slug_flag("--role", role)).transpose()?,
system: system.as_deref().map(|system| slug_flag("--system", system)).transpose()?,
source,
};
card::read(format, &bytes, &options)?
};
if let Some(description) = args.description {
bundle.header.description = Some(description);
}
if bundle.header.card.is_some()
&& let Some(game) = game.take()
{
bundle.header.game = Some(game);
}
let output = args.output.unwrap_or_else(|| args.input.with_extension(one_saves::EXTENSION));
let encoded = bundle.to_vec()?;
write_out(&output, &encoded, args.force)?;
if let Some((path, needed_instant)) = &sidecar_path {
let how = if !needed_instant {
"the instant it records".to_owned()
} else if args.rtc_captured_at.is_some() {
"the instant given".to_owned()
} else {
"the time it was read".to_owned()
};
println!("read the clock from {}, using {how}", path.display());
} else if one_saves_convert::rtc::footer_of(&bundle).is_some() {
println!("split a real-time-clock footer off the save, so its bytes hash the same over time");
}
let backup_ram = raw::is_segacd_bram(&bytes)
&& bundle.header.system.as_ref().is_some_and(|system| system.as_str() == "sega-cd");
if args.role.is_none() && backup_ram {
eprintln!("note: a Sega CD carries internal backup RAM and a Backup RAM Cart at once; pass");
eprintln!(" --role internal or --role ram-cart to say which socket this came out of");
}
let saves = bundle.parts.iter().filter(|p| p.kind == PartKind::Bundle).count();
let what = if saves > 0 { format!("{saves} save(s)") } else { format!("{} part(s)", bundle.parts.len()) };
println!(
"{} -> {} ({}, {what}, {} bytes)",
args.input.display(),
output.display(),
if backup_ram { "Sega CD backup RAM" } else { format.label() },
encoded.len()
);
Ok(())
}
fn slug_flag(flag: &str, text: &str) -> Result<one_saves::Slug, String> {
one_saves::Slug::parse(text).map_err(|e| format!("{flag} {text:?} is not a slug: {e}"))
}
fn source_of(
args: &Convert,
profile: Option<&Profile>,
) -> Result<Option<Source>, Box<dyn std::error::Error>> {
let mut source = profile.map(Profile::source).unwrap_or_default();
if let Some(text) = &args.core {
let core =
listed_name(text, "--core", |name| one_saves_registry::core_by_any_name(name).map(|c| c.slug))?;
if let Some(named) = &source.core
&& named != &core
{
return Err(format!("--from names the core {named}, and --core says {core}").into());
}
let app = source.app.as_ref().and_then(|app| one_saves_registry::app(app.as_str()));
if let Some(app) = app
&& let Some(listed) = one_saves_registry::core(core.as_str())
&& !app.runs(listed)
{
return Err(format!(
"{} does not run {}, so --core {text} cannot be right",
app.name, listed.name
)
.into());
}
source.core = Some(core);
}
if let Some(text) = &args.platform {
let platform = listed_name(text, "--platform", |name| {
one_saves_registry::platform_by_any_name(name).map(|p| p.slug)
})?;
source.platform = Some(platform);
}
for (flag, version, named, what) in [
("--app-version", &args.app_version, source.app.is_some(), "an app"),
("--core-version", &args.core_version, source.core.is_some(), "a core"),
("--platform-version", &args.platform_version, source.platform.is_some(), "a platform"),
] {
if version.is_some() && !named {
let hint = if flag == "--app-version" && source.core.is_some() {
"; --from names a core here, so its version is --core-version"
} else {
""
};
return Err(format!(
"{flag} was given, but nothing names {what} for it to be the version of{hint}"
)
.into());
}
}
source.app_version.clone_from(&args.app_version);
source.core_version.clone_from(&args.core_version);
source.platform_version.clone_from(&args.platform_version);
Ok((!source.is_empty()).then_some(source))
}
fn listed_name(
text: &str,
flag: &str,
lookup: impl Fn(&str) -> Option<&'static str>,
) -> Result<Name, String> {
if let Some(slug) = lookup(text) {
return Name::parse(slug).map_err(|e| format!("the registry slug {slug:?} is malformed: {e}"));
}
match Name::parse(text) {
Ok(name @ Name::ReverseDns(_)) => Ok(name),
Ok(Name::Slug(_)) => Err(format!(
"{flag} {text:?} is not one the registry lists; an unlisted one is written as its owner's \
reverse-DNS name"
)),
Err(e) => Err(format!("{flag} {text:?} is neither listed nor a well-formed name: {e}")),
}
}
#[derive(Default)]
struct Identified {
game: Option<one_saves::Game>,
system: Option<&'static str>,
clock: Option<&'static str>,
}
#[cfg(feature = "rom")]
fn identify(args: &Convert) -> Result<Identified, Box<dyn std::error::Error>> {
let Some(rom_path) = args.rom.as_deref() else {
return Ok(Identified::default());
};
let bytes = std::fs::read(rom_path)?;
let filename = rom_path.file_name().and_then(|n| n.to_str());
#[cfg_attr(not(feature = "dat"), allow(unused_mut))]
let mut game = one_saves_convert::rom::game_from_rom(&bytes, filename);
let info = one_saves_convert::rom::identify(&bytes).unwrap_or_default();
#[cfg(feature = "dat")]
if let Some(dat) = args.dat.as_deref() {
let catalog = one_saves_convert::dat::Catalog::open(dat)?;
if catalog.enrich(&mut game) {
println!("identified as {:?}", game.title.as_deref().unwrap_or_default());
} else {
eprintln!("note: this ROM is not in {}", dat.display());
}
}
Ok(Identified { game: Some(game), system: info.system, clock: info.clock })
}
fn extract(args: Extract) -> Fallible {
let bytes = std::fs::read(&args.input)?;
let bundle = Bundle::from_slice(&bytes)?;
if let Some(id) = args.part {
let payload = raw::unwrap_part(&bundle, id)?;
let output = args.output.unwrap_or_else(|| args.input.with_extension(format!("part{id}.bin")));
write_out(&output, &payload, args.force)?;
println!("part {id} -> {} ({} bytes)", output.display(), payload.len());
return Ok(());
}
let card_format = bundle.header.card.as_ref().map(|c| c.format.as_str().to_owned());
let target = match args.to.as_deref() {
Some(name) => Format::from_name(name).ok_or_else(|| format!("unknown format {name:?}"))?,
None => match card_format.as_deref() {
Some(slug) => Format::from_card_format(slug)
.ok_or_else(|| format!("this bundle is a {slug}, which this build cannot write"))?,
None => Format::Raw,
},
};
let payload = match target {
Format::Raw => raw::unwrap(&bundle)?,
Format::Bundle => return Err("extracting a bundle as a bundle is a copy".into()),
card => card::write(card, &bundle)?,
};
let is_backup_ram = bundle.header.system.as_ref().is_some_and(|s| s.as_str() == "sega-cd");
let suffix = if target == Format::Raw && is_backup_ram { "brm" } else { target.extension() };
let payload = match &args.rtc_file {
Some(path) => {
let sidecar = one_saves_convert::rtc::sidecar_of(&bundle)
.ok_or("this bundle carries no clock to write to a sidecar")?;
write_out(path, &sidecar, args.force)?;
println!("clock -> {} ({} bytes)", path.display(), sidecar.len());
raw::unwrap_save_only(&bundle)?
}
None => payload,
};
let output = args.output.unwrap_or_else(|| args.input.with_extension(suffix));
write_out(&output, &payload, args.force)?;
println!(
"{} -> {} ({}, {} bytes)",
args.input.display(),
output.display(),
if target == Format::Raw && is_backup_ram { "Sega CD backup RAM" } else { target.label() },
payload.len()
);
Ok(())
}
fn inspect(path: &Path) -> Fallible {
let bytes = std::fs::read(path)?;
let bundle = Bundle::from_slice(&bytes)?;
println!("{}", path.display());
println!(" {} bytes, spec version {}", bytes.len(), one_saves::SPEC_VERSION);
let mut inflatable = MAX_INFLATED;
describe(&bundle, 2, 0, &mut inflatable);
Ok(())
}
const LABEL_WIDTH: usize = 13;
const MAX_DEPTH: usize = 4;
const MAX_INFLATED: u64 = 192 << 20;
fn field(indent: usize, label: &str, value: &str) {
println!("{}", format!("{:indent$}{label:LABEL_WIDTH$}{value}", "").trim_end());
}
fn describe(bundle: &Bundle, indent: usize, depth: usize, inflatable: &mut u64) {
let header = &bundle.header;
let shape = bundle.shape();
if shape.is_known() {
field(indent, "shape", shape.as_str());
} else {
field(indent, "shape", &format!("{shape} (not a shape spec {} defines)", one_saves::SPEC_VERSION));
}
if let Some(system) = &header.system {
field(indent, "system", &system_name(system));
}
if let Some(card) = &header.card {
field(indent, "card", &format!("{}, capacity {} bytes", card.format, card.capacity));
if let Some(area) = &card.system_area {
field(indent, "", &format!("system area {}", shown_bytes(area)));
}
for line in unknown_keys(&card.unknown) {
field(indent, "", &format!("unknown {line}"));
}
}
if let Some(source) = &header.source {
describe_source(indent, source);
}
if let Some(seconds) = header.created_at {
field(indent, "created", &format!("{} UTC", Civil(seconds)));
}
match &header.game {
Some(game) => describe_game(indent, game),
None => field(indent, "game", "unidentified"),
}
if let Some(description) = &header.description {
field(indent, "description", &printable(description));
}
for key in header.extensions.keys() {
field(indent, "extension", &describe_extension(&header.extensions, key));
}
for line in unknown_keys(&header.unknown) {
field(indent, "unknown", &line);
}
field(indent, "parts", &bundle.parts.len().to_string());
for part in &bundle.parts {
describe_part(part, indent + 2, depth, inflatable);
}
}
fn describe_part(part: &Part, indent: usize, depth: usize, inflatable: &mut u64) {
let kind = part.kind.as_str().unwrap_or("save");
let role = part.role.as_ref().map_or("primary", |r| r.as_str());
let serial = printable(part.game.as_ref().and_then(|game| game.serial.as_deref()).unwrap_or(""));
let path = printable(part.path.as_deref().unwrap_or(""));
let slot = part.slot.map_or_else(String::new, |s| format!("slot {s}"));
let id = format!("[{}]", part.id);
let digest = part.sha256.to_string();
let short = digest.strip_prefix("sha256:").unwrap_or(&digest);
let line = format!(
"{:indent$}{id:<5} {kind:10} {role:16} {:>9} bytes {} {serial:12} {slot:8} {path}",
"",
part.payload.len(),
&short[..16]
);
println!("{}", line.trim_end());
let indent = indent + 6;
let open = || {
part.bytes()
.and_then(|bytes| Bundle::from_slice(&bytes))
.map_err(|e| format!("cannot be opened: {}", printable(&e.to_string())))
};
let inner = match &part.payload {
_ if !matches!(part.kind, PartKind::Bundle) => None,
_ if depth >= MAX_DEPTH => Some(Err(format!("not listed, being more than {MAX_DEPTH} deep"))),
Payload::Compressed { size, .. } if *size > *inflatable => Some(Err(format!(
"not inflated, claiming {size} bytes where this listing has {inflatable} left to inflate"
))),
Payload::Compressed { size, .. } => {
*inflatable -= size;
Some(open())
}
Payload::Embedded(_) => Some(open()),
};
let held = inner.as_ref().and_then(|inner| inner.as_ref().ok()).map(|inner| &inner.header);
if let Some(source) = &part.source {
describe_source(indent, source);
}
if let Some(game) = &part.game
&& held.is_none_or(|held| held.game.as_ref() != Some(game))
{
describe_game(indent, game);
}
if let Some(system) = &part.system
&& held.is_none_or(|held| held.system.as_ref() != Some(system))
{
field(indent, "system", &system_name(system));
}
if let Some(binding) = &part.binding {
let listed = if one_saves_registry::is_binding(binding.as_str()) { "" } else { " (unlisted)" };
field(indent, "binding", &format!("{binding}{listed}"));
}
if let Some(content_type) = &part.content_type {
field(indent, "content type", &printable(content_type));
}
if let Payload::Compressed { bytes, .. } = &part.payload {
field(indent, "encoding", &format!("zstd, {} bytes stored", bytes.len()));
}
if let Some(dirent) = &part.dirent {
field(indent, "dirent", &shown_bytes(dirent));
}
for key in part.extensions.keys() {
field(indent, "extension", &describe_extension(&part.extensions, key));
}
for line in unknown_keys(&part.unknown) {
field(indent, "unknown", &line);
}
match inner {
Some(Ok(inner)) => describe(&inner, indent + 2, depth + 1, inflatable),
Some(Err(why)) => field(indent, "bundle", &why),
None => {}
}
}
fn describe_source(indent: usize, source: &Source) {
let app = layer("app", "", source.app.as_ref(), source.app_version.as_deref(), |slug| {
one_saves_registry::app(slug).map(|app| app.name)
});
let core = layer("core", "core ", source.core.as_ref(), source.core_version.as_deref(), |slug| {
one_saves_registry::core(slug).map(|core| core.name)
});
let platform = layer(
"platform",
"platform ",
source.platform.as_ref(),
source.platform_version.as_deref(),
|slug| one_saves_registry::platform(slug).map(|platform| platform.name),
);
let kind = source.device_kind.as_ref().map(|kind| {
let listed = if one_saves_registry::is_device_kind(kind.as_str()) { "" } else { " (unlisted)" };
format!("{kind}{listed}")
});
let bits: Vec<String> = [app, core, platform, kind].into_iter().flatten().collect();
field(indent, "source", &bits.join(", "));
if let Some(fingerprint) = &source.fingerprint {
field(indent, "", &format!("fingerprint {}", printable(fingerprint)));
}
for line in unknown_keys(&source.unknown) {
field(indent, "", &format!("unknown {line}"));
}
}
fn layer(
what: &str,
label: &str,
name: Option<&Name>,
version: Option<&str>,
lookup: impl Fn(&str) -> Option<&'static str>,
) -> Option<String> {
let name = name.map(|name| match name.as_slug() {
Some(slug) => format!("{name} ({})", lookup(slug.as_str()).unwrap_or("unlisted")),
None => name.to_string(),
});
match (name, version.map(printable)) {
(Some(name), Some(version)) => Some(format!("{label}{name} {version}")),
(Some(name), None) => Some(format!("{label}{name}")),
(None, Some(version)) => Some(format!("{what} version {version}")),
(None, None) => None,
}
}
fn describe_game(indent: usize, game: &Game) {
let bits: Vec<String> = [&game.title, &game.serial, &game.rom_filename]
.into_iter()
.flatten()
.map(|text| printable(text))
.collect();
field(indent, "game", &bits.join(", "));
if let Some(system) = &game.system {
field(indent, "", &format!("system {}", system_name(system)));
}
for hash in &game.rom_hashes {
field(indent, "", &hash.to_string());
}
for (resolver, id) in &game.game_id {
let id = match id {
one_saves::GameId::Uint(n) => n.to_string(),
one_saves::GameId::Text(t) => printable(t),
};
field(indent, "", &format!("{resolver} = {id}"));
}
for key in game.extensions.keys() {
field(indent, "", &format!("extension {}", describe_extension(&game.extensions, key)));
}
for line in unknown_keys(&game.unknown) {
field(indent, "", &format!("unknown {line}"));
}
}
fn system_name(system: &one_saves::Slug) -> String {
format!("{system} ({})", one_saves_registry::system(system.as_str()).map_or("unlisted", |s| s.name))
}
fn unknown_keys(keys: &one_saves::UnknownKeys) -> impl Iterator<Item = String> + '_ {
keys.iter().map(|(key, value)| {
let text = value.diagnostic_flat();
let text = match text.char_indices().nth(SHOWN_CHARS - 1) {
Some((cut, _)) if text.chars().nth(SHOWN_CHARS).is_some() => format!("{}…", &text[..cut]),
_ => text,
};
format!("key {key} = {}", printable(&text))
})
}
fn shown_bytes(bytes: &[u8]) -> String {
let mut head = String::new();
for byte in bytes.iter().take(SHOWN_BYTES) {
write!(head, "{byte:02x}").expect("a String takes any write");
}
let more = if bytes.len() > SHOWN_BYTES { "…" } else { "" };
format!("h'{head}{more}' ({} bytes)", bytes.len())
}
fn describe_extension(extensions: &one_saves::Extensions, key: &one_saves::ReverseDnsName) -> String {
match extensions.display(key) {
Some(value) => format!("{key} = {}", printable(&value.to_string())),
None => key.to_string(),
}
}
fn printable(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for character in text.chars() {
if character.is_control() {
out.extend(character.escape_default());
} else {
out.push(character);
}
}
out
}
fn verify(path: &Path) -> Fallible {
let bytes = std::fs::read(path)?;
let bundle = Bundle::from_slice(&bytes)?;
let mut bad = 0;
for part in &bundle.parts {
match part.verify() {
Ok(true) => {}
Ok(false) => {
bad += 1;
eprintln!(" part {}: sha256 does not match its payload", part.id);
}
Err(e) => {
bad += 1;
eprintln!(" part {}: {e}", part.id);
}
}
}
if bundle.to_vec()? != bytes {
bad += 1;
eprintln!(" this file is not the deterministic encoding of what it says");
}
if bad > 0 {
return Err(format!("{bad} problem(s) in {}", path.display()).into());
}
println!("{}: ok ({} part(s))", path.display(), bundle.parts.len());
Ok(())
}
fn hash(path: &Path) -> Fallible {
let bytes = std::fs::read(path)?;
let bundle = Bundle::from_slice(&bytes)?;
println!("file {}", one_saves::HashValue::sha256_of(&bytes));
match bundle.content_hash() {
Ok(content) => println!("content {content}"),
Err(e) => println!("content unavailable: {e}"),
}
Ok(())
}
fn profiles() {
println!("{:<14} {:<18} {:<12} {:<18} SYSTEMS", "NAME", "DEVICE KIND", "APP", "CORE");
for profile in one_saves_convert::profile::profiles() {
let systems =
if profile.systems.is_empty() { "(any)".to_owned() } else { profile.systems.join(", ") };
println!(
"{:<14} {:<18} {:<12} {:<18} {systems}",
profile.key,
profile.device_kind,
profile.app.unwrap_or("-"),
profile.core.unwrap_or("-")
);
}
println!(
"\nA dash is a name neither registry lists, which a bundle leaves out rather than invent one for.\n\
Any of the {} apps and {} cores in the registry also works by slug or name, and --core and\n\
--platform name the core an app ran and the firmware it ran on.",
one_saves_registry::apps().len(),
one_saves_registry::cores().len()
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn text_from_a_bundle_cannot_drive_the_terminal() {
assert_eq!(printable("FINAL FANTASY VII"), "FINAL FANTASY VII");
assert_eq!(printable("TEKKEN 4"), "TEKKEN 4");
assert_eq!(printable("\u{1b}]0;owned\u{7}\u{1b}[2J"), r"\u{1b}]0;owned\u{7}\u{1b}[2J");
assert_eq!(printable("two\nlines\u{9b}"), r"two\nlines\u{9b}");
}
}