use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use serde::Deserialize;
use std::borrow::Cow;
use crate::{CommandNode, ExecSpec, Metadata, RootSpec};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HostPlatform {
pub id: Cow<'static, str>,
}
impl HostPlatform {
pub fn detect() -> Self {
if let Ok(v) = std::env::var("JAN_OS") {
let s = v.trim().to_ascii_lowercase();
if !s.is_empty() {
return Self::from_normalized(&s);
}
}
Self::from_normalized(std::env::consts::OS)
}
fn from_normalized(os: &str) -> Self {
let id = match os {
"darwin" | "macos" => Cow::Borrowed("macos"),
"linux" => Cow::Borrowed("linux"),
"windows" => Cow::Borrowed("windows"),
other => Cow::Owned(other.to_string()),
};
Self { id }
}
}
fn normalize_os_token(tok: &str) -> String {
match tok.trim().to_ascii_lowercase().as_str() {
"darwin" => "macos".to_string(),
s => s.to_string(),
}
}
fn node_visible_for_platform(os_list: &[String], platform: &str) -> bool {
if os_list.is_empty() {
return true;
}
os_list.iter().any(|o| normalize_os_token(o) == platform)
}
#[derive(Debug, Deserialize)]
struct RawRootSpec {
metadata: Option<Metadata>,
#[serde(default)]
include: Vec<String>,
#[serde(default)]
commands: BTreeMap<String, RawCommandNode>,
}
#[derive(Debug, Deserialize)]
struct RawCommandNode {
#[serde(default)]
os: Vec<String>,
#[serde(default)]
about: String,
path: Option<String>,
#[serde(default)]
dependencies: Vec<String>,
#[serde(default)]
requires: Vec<String>,
#[serde(default)]
env: BTreeMap<String, String>,
include: Option<String>,
#[serde(default)]
commands: BTreeMap<String, RawCommandNode>,
exec: Option<ExecSpec>,
}
#[derive(Clone)]
struct LoadCtx {
include_base: PathBuf,
}
impl LoadCtx {
fn read_include(&self, rel: &str) -> Result<String> {
let rel = rel.trim();
if rel.is_empty() {
bail!("empty include path");
}
let path = resolve_under(&self.include_base, rel)?;
std::fs::read_to_string(&path)
.with_context(|| format!("read included spec {}", path.display()))
}
fn ctx_for_nested_include(&self, rel: &str) -> Result<LoadCtx> {
let path = resolve_under(&self.include_base, rel)?;
Ok(LoadCtx {
include_base: path
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf(),
})
}
fn visit_token(&self, rel: &str) -> Result<String> {
let p = resolve_under(&self.include_base, rel)?;
Ok(p.to_string_lossy().to_string())
}
}
fn resolve_under(base_dir: &Path, rel: &str) -> Result<PathBuf> {
let p = Path::new(rel);
let full = if p.is_absolute() {
p.to_path_buf()
} else {
base_dir.join(p)
};
full.canonicalize()
.with_context(|| format!("include path not found: {}", full.display()))
}
fn merge_os_filters(outer: &[String], inner: &[String]) -> Result<Vec<String>> {
match (outer.is_empty(), inner.is_empty()) {
(true, true) => Ok(vec![]),
(true, false) => Ok(inner.to_vec()),
(false, true) => Ok(outer.to_vec()),
(false, false) => {
let merged: Vec<String> = outer
.iter()
.filter(|o| {
let n = normalize_os_token(o);
inner.iter().any(|i| normalize_os_token(i) == n)
})
.cloned()
.collect();
if merged.is_empty() {
bail!(
"conflicting `os:` filters between include wrapper and included file \
(no platform appears in both lists)"
);
}
Ok(merged)
}
}
}
fn overlay_about(overlay: &str, base: String) -> String {
let o = overlay.trim();
if o.is_empty() {
base
} else {
o.to_string()
}
}
fn resolve_raw_command_node(
raw: RawCommandNode,
ctx: &LoadCtx,
visited: &mut HashSet<String>,
) -> Result<CommandNode> {
if raw.include.is_some() && (raw.exec.is_some() || !raw.commands.is_empty()) {
bail!("command with `include` cannot also define `exec` or nested `commands` in the same YAML map");
}
let mut raw = raw;
if let Some(rel) = raw.include.take() {
let token = ctx.visit_token(&rel)?;
if !visited.insert(token.clone()) {
bail!("include cycle detected at `{token}`");
}
let text = ctx.read_include(&rel)?;
let inner: RawCommandNode =
serde_yaml::from_str(&text).with_context(|| format!("parse include `{rel}`"))?;
let nested_ctx = ctx.ctx_for_nested_include(&rel)?;
let mut node = resolve_raw_command_node(inner, &nested_ctx, visited)?;
visited.remove(&token);
node.os = merge_os_filters(&raw.os, &node.os)?;
node.about = overlay_about(&raw.about, node.about);
return Ok(node);
}
let mut commands = BTreeMap::new();
for (name, child) in raw.commands {
commands.insert(name, resolve_raw_command_node(child, ctx, visited)?);
}
Ok(CommandNode {
os: raw.os,
about: raw.about,
path: raw.path,
dependencies: raw.dependencies,
requires: raw.requires,
env: raw.env,
commands,
exec: raw.exec,
})
}
fn merge_root_includes(mut root: RawRootSpec, include_base: &Path) -> Result<RawRootSpec> {
let base_dir = include_base;
let mut merged = BTreeMap::new();
for inc in &root.include {
let path = resolve_under(base_dir, inc)?;
let text = std::fs::read_to_string(&path)
.with_context(|| format!("read root include {}", path.display()))?;
let fragment: RawRootSpec =
serde_yaml::from_str(&text).with_context(|| format!("parse {}", path.display()))?;
let frag_base = path
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf();
let mut expanded = merge_root_includes(fragment, &frag_base)?;
merged.append(&mut expanded.commands);
}
merged.append(&mut root.commands);
root.commands = merged;
root.include.clear();
Ok(root)
}
fn materialize_root(raw: RawRootSpec, ctx: &LoadCtx) -> Result<RootSpec> {
let mut visited = HashSet::new();
let mut commands = BTreeMap::new();
for (name, node) in raw.commands {
commands.insert(name, resolve_raw_command_node(node, ctx, &mut visited)?);
}
Ok(RootSpec {
metadata: raw.metadata,
commands,
})
}
fn validate_root(spec: &RootSpec) -> Result<()> {
for (name, node) in &spec.commands {
node.validate(name)?;
}
Ok(())
}
pub fn load_spec_from_path(spec_path: &Path, platform: HostPlatform) -> Result<RootSpec> {
let text = std::fs::read_to_string(spec_path)
.with_context(|| format!("read spec file {}", spec_path.display()))?;
let raw: RawRootSpec = serde_yaml::from_str(&text).context("parse YAML spec")?;
let include_base = spec_path
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf();
let raw = merge_root_includes(raw, &include_base)?;
let ctx = LoadCtx { include_base };
let mut spec = materialize_root(raw, &ctx)?;
validate_root(&spec)?;
filter_spec_for_platform(&mut spec, platform.id.as_ref());
Ok(spec)
}
pub fn load_spec_from_str(
raw: &str,
include_base: Option<&Path>,
platform: HostPlatform,
) -> Result<RootSpec> {
let raw: RawRootSpec = serde_yaml::from_str(raw).context("parse YAML spec")?;
let raw = if let Some(base) = include_base {
merge_root_includes(raw, base)?
} else {
if !raw.include.is_empty() {
bail!("root-level `include` requires a base directory (pass when calling load_spec_from_str)");
}
raw
};
let ctx = LoadCtx {
include_base: include_base
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from(".")),
};
let mut spec = materialize_root(raw, &ctx)?;
validate_root(&spec)?;
filter_spec_for_platform(&mut spec, platform.id.as_ref());
Ok(spec)
}
pub fn filter_spec_for_platform(spec: &mut RootSpec, platform_id: &str) {
filter_command_map(&mut spec.commands, platform_id);
}
fn filter_command_map(map: &mut BTreeMap<String, CommandNode>, platform_id: &str) {
map.retain(|_, node| {
if !node_visible_for_platform(&node.os, platform_id) {
return false;
}
filter_command_map(&mut node.commands, platform_id);
if node.exec.is_some() {
return true;
}
!node.commands.is_empty()
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ExecSpec;
#[test]
fn os_filter_drops_linux_only_branch() {
let mut spec = RootSpec {
metadata: None,
commands: BTreeMap::from([(
"sys".into(),
CommandNode {
os: vec!["linux".into()],
about: "linux".into(),
commands: BTreeMap::from([(
"ports".into(),
CommandNode {
exec: Some(ExecSpec {
argv: vec!["echo".into(), "x".into()],
passthrough: false,
}),
..Default::default()
},
)]),
..Default::default()
},
)]),
};
filter_spec_for_platform(&mut spec, "macos");
assert!(spec.commands.is_empty());
}
}