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)]
enum LoadCtx<'a> {
File {
include_base: PathBuf,
},
Embedded {
virtual_dir: String,
map: &'a [(&'static str, &'static str)],
},
}
impl<'a> LoadCtx<'a> {
fn read_include(&self, rel: &str) -> Result<String> {
let rel = rel.trim();
if rel.is_empty() {
bail!("empty include path");
}
match self {
LoadCtx::File { include_base } => {
let path = resolve_under(include_base, rel)?;
std::fs::read_to_string(&path)
.with_context(|| format!("read included spec {}", path.display()))
}
LoadCtx::Embedded { virtual_dir, map } => {
let key = embedded_slash_path(virtual_dir, rel);
map.iter()
.find(|(k, _)| *k == key.as_str())
.map(|(_, v)| (*v).to_string())
.with_context(|| format!("missing embedded include `{key}`"))
}
}
}
fn ctx_for_nested_include(&self, rel: &str) -> Result<LoadCtx<'a>> {
match self {
LoadCtx::File { include_base } => {
let path = resolve_under(include_base, rel)?;
Ok(LoadCtx::File {
include_base: path
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf(),
})
}
LoadCtx::Embedded { virtual_dir, map } => {
let _ = self.read_include(rel)?;
let key = embedded_slash_path(virtual_dir, rel);
let parent = Path::new(&key)
.parent()
.map(|p| p.to_string_lossy().replace('\\', "/"))
.filter(|s| !s.is_empty())
.unwrap_or_default();
Ok(LoadCtx::Embedded {
virtual_dir: parent,
map: *map,
})
}
}
}
fn visit_token(&self, rel: &str) -> Result<String> {
match self {
LoadCtx::File { include_base } => {
let p = resolve_under(include_base, rel)?;
Ok(p.to_string_lossy().to_string())
}
LoadCtx::Embedded { virtual_dir, .. } => Ok(embedded_slash_path(virtual_dir, rel)),
}
}
}
fn embedded_slash_path(virtual_dir: &str, rel: &str) -> String {
let rel = rel.trim();
let p = if Path::new(rel).is_absolute() {
PathBuf::from(rel)
} else {
Path::new(virtual_dir).join(rel)
};
p.components()
.filter(|c| !matches!(c, std::path::Component::ParentDir))
.collect::<PathBuf>()
.to_string_lossy()
.replace('\\', "/")
.trim_start_matches('/')
.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 merge_root_includes_embedded(
mut root: RawRootSpec,
virtual_dir: &str,
map: &[(&'static str, &'static str)],
) -> Result<RawRootSpec> {
let ctx = LoadCtx::Embedded {
virtual_dir: virtual_dir.to_string(),
map,
};
let mut merged = BTreeMap::new();
for inc in &root.include {
let text = ctx
.read_include(inc)
.with_context(|| format!("read embedded root include `{inc}`"))?;
let fragment: RawRootSpec = serde_yaml::from_str(&text)
.with_context(|| format!("parse embedded root include `{inc}`"))?;
let child_vdir = Path::new(virtual_dir)
.join(inc)
.parent()
.map(|p| p.to_string_lossy().replace('\\', "/"))
.filter(|s| !s.is_empty())
.unwrap_or_default();
let mut expanded = merge_root_includes_embedded(fragment, &child_vdir, map)?;
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::File { 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_with_embedded(
raw: &str,
virtual_dir: &str,
embedded: &[(&'static str, &'static str)],
platform: HostPlatform,
) -> Result<RootSpec> {
let mut raw_spec: RawRootSpec = serde_yaml::from_str(raw).context("parse YAML spec")?;
raw_spec = merge_root_includes_embedded(raw_spec, virtual_dir, embedded)?;
let ctx = LoadCtx::Embedded {
virtual_dir: virtual_dir.to_string(),
map: embedded,
};
let mut spec = materialize_root(raw_spec, &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::File {
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());
}
}