use std::path::{Path, PathBuf};
use anyhow::Context;
use powerio_matrix::format::routing::{Detection, JsonClass, SourceFormat as DetectedFormat};
use powerio_matrix::network::Network;
use powerio_pkg::{MulticonductorToBalancedOptions, lower_multiconductor_to_balanced};
pub const TRANSMISSION_EXTENSIONS: &[&str] = &["m", "raw", "aux", "epc", "pwb"];
pub const DISTRIBUTION_EXTENSIONS: &[&str] = &["dss"];
const JSON_EXTENSION: &str = "json";
pub const CASE_EXTENSIONS_LABEL: &str = ".m, .raw, .aux, .epc, .pwb, .json, .dss";
pub fn infer_input_family(input: &Path) -> anyhow::Result<Option<bool>> {
let ext = input
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase);
let Some(ext) = ext.as_deref() else {
return Ok(None);
};
if TRANSMISSION_EXTENSIONS.contains(&ext) {
return Ok(Some(false));
}
if DISTRIBUTION_EXTENSIONS.contains(&ext) {
return Ok(Some(true));
}
if ext != JSON_EXTENSION {
return Ok(None);
}
let text = std::fs::read_to_string(input)
.with_context(|| format!("reading JSON format markers from {}", input.display()))?;
match classify_case_json(&text, input)? {
DetectedFormat::Distribution(_) => Ok(Some(true)),
DetectedFormat::Transmission(_) => Ok(Some(false)),
other => anyhow::bail!(
"unrecognized JSON format family `{}` in {}; pass --from to choose a format",
other.name(),
input.display()
),
}
}
fn classify_case_json(text: &str, path: &Path) -> anyhow::Result<DetectedFormat> {
match powerio_matrix::format::routing::classify_json_text(text) {
JsonClass::Case(Detection::Known(format)) => Ok(format),
JsonClass::Package => anyhow::bail!(
"{} is a .pio.json package envelope, not a case file; the `package` \
subcommand writes envelopes, and the bindings read them \
(powerio.Package.from_json in Python, read_package in Julia)",
path.display()
),
JsonClass::Case(Detection::Ambiguous) => anyhow::bail!(
"ambiguous JSON markers in {}; pass --from to choose a format",
path.display()
),
JsonClass::Case(Detection::Unknown) => anyhow::bail!(
"cannot infer JSON format for {}; pass --from to choose a format",
path.display()
),
}
}
pub fn looks_like_distribution_input(input: &Path) -> anyhow::Result<bool> {
Ok(infer_input_family(input)?.unwrap_or(false))
}
pub fn discover_cases(root: &Path, exclude: Option<&Path>) -> Vec<PathBuf> {
let excluded = exclude
.and_then(|p| p.canonicalize().ok())
.filter(|p| root.canonicalize().ok().as_ref() != Some(p));
let mut cases: Vec<PathBuf> = walkdir::WalkDir::new(root)
.into_iter()
.filter_entry(|e| !is_hidden(e) && !is_excluded_dir(e, excluded.as_deref()))
.filter_map(std::result::Result::ok)
.filter(|e| e.file_type().is_file() && has_case_extension(e.path()))
.map(walkdir::DirEntry::into_path)
.collect();
cases.sort();
cases
}
fn is_hidden(entry: &walkdir::DirEntry) -> bool {
entry.depth() > 0
&& entry
.file_name()
.to_str()
.is_some_and(|n| n.starts_with('.'))
}
fn is_excluded_dir(entry: &walkdir::DirEntry, excluded: Option<&Path>) -> bool {
let Some(excluded) = excluded else {
return false;
};
entry.file_type().is_dir() && entry.path().canonicalize().is_ok_and(|p| p == excluded)
}
fn has_case_extension(path: &Path) -> bool {
let Some(ext) = path
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase)
else {
return false;
};
let ext = ext.as_str();
TRANSMISSION_EXTENSIONS.contains(&ext)
|| DISTRIBUTION_EXTENSIONS.contains(&ext)
|| ext == JSON_EXTENSION
}
pub struct LoadedCase {
pub network: Network,
pub warnings: Vec<String>,
}
pub fn load_network(path: &Path) -> anyhow::Result<LoadedCase> {
let ext = path
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase);
let stem = path.file_stem().and_then(|s| s.to_str());
match ext.as_deref() {
Some(JSON_EXTENSION) => {
let text = std::fs::read_to_string(path)
.with_context(|| format!("reading {}", path.display()))?;
match classify_case_json(&text, path)? {
DetectedFormat::Distribution(format) => {
let net = powerio_dist::parse_str(&text, format.name())
.with_context(|| format!("parse {}", path.display()))?;
lower_to_balanced(net, stem, path)
}
DetectedFormat::Transmission(format) => {
let parsed =
powerio_matrix::format::parse_str_with_name(&text, format.name(), stem)
.with_context(|| format!("parse {}", path.display()))?;
Ok(LoadedCase {
network: parsed.network,
warnings: parsed.warnings,
})
}
other => anyhow::bail!(
"unrecognized JSON format family `{}` in {}; pass --from to choose a format",
other.name(),
path.display()
),
}
}
Some(ext) if DISTRIBUTION_EXTENSIONS.contains(&ext) => {
let net = powerio_dist::parse_file(path, None)
.with_context(|| format!("parse {}", path.display()))?;
lower_to_balanced(net, stem, path)
}
Some(ext) if TRANSMISSION_EXTENSIONS.contains(&ext) => {
let parsed = powerio_matrix::parse_file(path, None)
.with_context(|| format!("parse {}", path.display()))?;
Ok(LoadedCase {
network: parsed.network,
warnings: parsed.warnings,
})
}
_ => anyhow::bail!("cannot infer a case format for {}", path.display()),
}
}
fn lower_to_balanced(
mut net: powerio_dist::DistNetwork,
stem: Option<&str>,
path: &Path,
) -> anyhow::Result<LoadedCase> {
if net.name.is_none() {
net.name = stem.map(str::to_owned);
}
let lowered =
lower_multiconductor_to_balanced(&net, MulticonductorToBalancedOptions::default())
.map_err(|e| {
let diagnostics = e
.diagnostics
.iter()
.map(|d| d.message.as_str())
.collect::<Vec<_>>()
.join("; ");
if diagnostics.is_empty() {
anyhow::anyhow!("lower {} to balanced: {e}", path.display())
} else {
anyhow::anyhow!("lower {} to balanced: {diagnostics}", path.display())
}
})?;
let mut warnings = net.warnings;
warnings.extend(
(lowered.record.approximations.iter())
.chain(&lowered.record.dropped_fields)
.map(|s| format!("lowering: {s}")),
);
Ok(LoadedCase {
network: lowered.network,
warnings,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn touch(path: &Path) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, "").unwrap();
}
#[test]
fn discovers_recursively_sorted_and_prunes() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
for rel in [
"case1.m",
"a/case2.json",
"a/net.aux",
"a/b/c/deep.raw",
"a/b/c/w.pwb",
"a/b/c/sys.epc",
"a/b/feeder.dss",
"notes.txt",
"display.pwd",
".hidden.m",
".git/skip.m",
"out/case1_meta.json",
] {
touch(&root.join(rel));
}
let found = discover_cases(root, Some(&root.join("out")));
let expected: Vec<PathBuf> = [
"a/b/c/deep.raw",
"a/b/c/sys.epc",
"a/b/c/w.pwb",
"a/b/feeder.dss",
"a/case2.json",
"a/net.aux",
"case1.m",
]
.iter()
.map(|rel| root.join(rel))
.collect();
assert_eq!(found, expected);
}
#[test]
fn extension_match_ignores_case() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
for rel in ["CASE.M", "net.RAW", "feeder.DSS", "case.Json", "w.PwB"] {
touch(&root.join(rel));
}
assert_eq!(discover_cases(root, None).len(), 5);
}
#[test]
fn exclude_equal_to_root_is_ignored() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
touch(&root.join("case1.m"));
assert_eq!(discover_cases(root, Some(root)), vec![root.join("case1.m")]);
}
#[test]
fn missing_root_yields_empty() {
let tmp = tempfile::tempdir().unwrap();
assert!(discover_cases(&tmp.path().join("nope"), None).is_empty());
}
#[test]
fn nameless_distribution_json_takes_the_file_stem_name() {
let dss = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../tests/data/dist/micro/fourwire_linecode.dss"
);
let conv = powerio_dist::convert_file(dss, powerio_dist::DistTargetFormat::BmopfJson, None)
.unwrap();
let mut doc: serde_json::Value = serde_json::from_str(&conv.text).unwrap();
doc.as_object_mut().unwrap().remove("name");
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("myfeeder.json");
std::fs::write(&path, doc.to_string()).unwrap();
let loaded = load_network(&path).unwrap();
assert_eq!(loaded.network.name, "myfeeder");
}
#[test]
fn label_lists_every_extension() {
for ext in TRANSMISSION_EXTENSIONS
.iter()
.chain(DISTRIBUTION_EXTENSIONS)
.chain(std::iter::once(&JSON_EXTENSION))
{
assert!(
CASE_EXTENSIONS_LABEL.contains(&format!(".{ext}")),
"label is missing .{ext}"
);
}
}
}