1use std::collections::{BTreeMap, HashSet};
4use std::path::{Path, PathBuf};
5
6use anyhow::{bail, Context, Result};
7use serde::Deserialize;
8use std::borrow::Cow;
9
10use crate::{CommandNode, ExecSpec, Metadata, RootSpec};
11
12#[derive(Clone, Debug, PartialEq, Eq)]
14pub struct HostPlatform {
15 pub id: Cow<'static, str>,
17}
18
19impl HostPlatform {
20 pub fn detect() -> Self {
22 if let Ok(v) = std::env::var("JAN_OS") {
23 let s = v.trim().to_ascii_lowercase();
24 if !s.is_empty() {
25 return Self::from_normalized(&s);
26 }
27 }
28 Self::from_normalized(std::env::consts::OS)
29 }
30
31 fn from_normalized(os: &str) -> Self {
32 let id = match os {
33 "darwin" | "macos" => Cow::Borrowed("macos"),
34 "linux" => Cow::Borrowed("linux"),
35 "windows" => Cow::Borrowed("windows"),
36 other => Cow::Owned(other.to_string()),
37 };
38 Self { id }
39 }
40}
41
42fn normalize_os_token(tok: &str) -> String {
43 match tok.trim().to_ascii_lowercase().as_str() {
44 "darwin" => "macos".to_string(),
45 s => s.to_string(),
46 }
47}
48
49fn node_visible_for_platform(os_list: &[String], platform: &str) -> bool {
50 if os_list.is_empty() {
51 return true;
52 }
53 os_list.iter().any(|o| normalize_os_token(o) == platform)
54}
55
56#[derive(Debug, Deserialize)]
57struct RawRootSpec {
58 metadata: Option<Metadata>,
59 #[serde(default)]
60 include: Vec<String>,
61 #[serde(default)]
62 commands: BTreeMap<String, RawCommandNode>,
63}
64
65#[derive(Debug, Deserialize)]
66struct RawCommandNode {
67 #[serde(default)]
68 os: Vec<String>,
69 #[serde(default)]
70 about: String,
71 path: Option<String>,
72 #[serde(default)]
73 dependencies: Vec<String>,
74 #[serde(default)]
75 requires: Vec<String>,
76 #[serde(default)]
77 env: BTreeMap<String, String>,
78 include: Option<String>,
79 #[serde(default)]
80 commands: BTreeMap<String, RawCommandNode>,
81 exec: Option<ExecSpec>,
82}
83
84#[derive(Clone)]
85struct LoadCtx {
86 include_base: PathBuf,
88}
89
90impl LoadCtx {
91 fn read_include(&self, rel: &str) -> Result<String> {
92 let rel = rel.trim();
93 if rel.is_empty() {
94 bail!("empty include path");
95 }
96 let path = resolve_under(&self.include_base, rel)?;
97 std::fs::read_to_string(&path)
98 .with_context(|| format!("read included spec {}", path.display()))
99 }
100
101 fn ctx_for_nested_include(&self, rel: &str) -> Result<LoadCtx> {
102 let path = resolve_under(&self.include_base, rel)?;
103 Ok(LoadCtx {
104 include_base: path
105 .parent()
106 .unwrap_or_else(|| Path::new("."))
107 .to_path_buf(),
108 })
109 }
110
111 fn visit_token(&self, rel: &str) -> Result<String> {
112 let p = resolve_under(&self.include_base, rel)?;
113 Ok(p.to_string_lossy().to_string())
114 }
115}
116
117fn resolve_under(base_dir: &Path, rel: &str) -> Result<PathBuf> {
118 let p = Path::new(rel);
119 let full = if p.is_absolute() {
120 p.to_path_buf()
121 } else {
122 base_dir.join(p)
123 };
124 full.canonicalize()
125 .with_context(|| format!("include path not found: {}", full.display()))
126}
127
128fn merge_os_filters(outer: &[String], inner: &[String]) -> Result<Vec<String>> {
129 match (outer.is_empty(), inner.is_empty()) {
130 (true, true) => Ok(vec![]),
131 (true, false) => Ok(inner.to_vec()),
132 (false, true) => Ok(outer.to_vec()),
133 (false, false) => {
134 let merged: Vec<String> = outer
135 .iter()
136 .filter(|o| {
137 let n = normalize_os_token(o);
138 inner.iter().any(|i| normalize_os_token(i) == n)
139 })
140 .cloned()
141 .collect();
142 if merged.is_empty() {
143 bail!(
144 "conflicting `os:` filters between include wrapper and included file \
145 (no platform appears in both lists)"
146 );
147 }
148 Ok(merged)
149 }
150 }
151}
152
153fn overlay_about(overlay: &str, base: String) -> String {
154 let o = overlay.trim();
155 if o.is_empty() {
156 base
157 } else {
158 o.to_string()
159 }
160}
161
162fn resolve_raw_command_node(
163 raw: RawCommandNode,
164 ctx: &LoadCtx,
165 visited: &mut HashSet<String>,
166) -> Result<CommandNode> {
167 if raw.include.is_some() && (raw.exec.is_some() || !raw.commands.is_empty()) {
168 bail!("command with `include` cannot also define `exec` or nested `commands` in the same YAML map");
169 }
170
171 let mut raw = raw;
172 if let Some(rel) = raw.include.take() {
173 let token = ctx.visit_token(&rel)?;
174 if !visited.insert(token.clone()) {
175 bail!("include cycle detected at `{token}`");
176 }
177 let text = ctx.read_include(&rel)?;
178 let inner: RawCommandNode =
179 serde_yaml::from_str(&text).with_context(|| format!("parse include `{rel}`"))?;
180 let nested_ctx = ctx.ctx_for_nested_include(&rel)?;
181 let mut node = resolve_raw_command_node(inner, &nested_ctx, visited)?;
182 visited.remove(&token);
183 node.os = merge_os_filters(&raw.os, &node.os)?;
184 node.about = overlay_about(&raw.about, node.about);
185 return Ok(node);
186 }
187
188 let mut commands = BTreeMap::new();
189 for (name, child) in raw.commands {
190 commands.insert(name, resolve_raw_command_node(child, ctx, visited)?);
191 }
192
193 Ok(CommandNode {
194 os: raw.os,
195 about: raw.about,
196 path: raw.path,
197 dependencies: raw.dependencies,
198 requires: raw.requires,
199 env: raw.env,
200 commands,
201 exec: raw.exec,
202 })
203}
204
205fn merge_root_includes(mut root: RawRootSpec, include_base: &Path) -> Result<RawRootSpec> {
206 let base_dir = include_base;
207 let mut merged = BTreeMap::new();
208 for inc in &root.include {
209 let path = resolve_under(base_dir, inc)?;
210 let text = std::fs::read_to_string(&path)
211 .with_context(|| format!("read root include {}", path.display()))?;
212 let fragment: RawRootSpec =
213 serde_yaml::from_str(&text).with_context(|| format!("parse {}", path.display()))?;
214 let frag_base = path
215 .parent()
216 .unwrap_or_else(|| Path::new("."))
217 .to_path_buf();
218 let mut expanded = merge_root_includes(fragment, &frag_base)?;
219 merged.append(&mut expanded.commands);
220 }
221 merged.append(&mut root.commands);
222 root.commands = merged;
223 root.include.clear();
224 Ok(root)
225}
226
227fn materialize_root(raw: RawRootSpec, ctx: &LoadCtx) -> Result<RootSpec> {
228 let mut visited = HashSet::new();
229 let mut commands = BTreeMap::new();
230 for (name, node) in raw.commands {
231 commands.insert(name, resolve_raw_command_node(node, ctx, &mut visited)?);
232 }
233 Ok(RootSpec {
234 metadata: raw.metadata,
235 commands,
236 })
237}
238
239fn validate_root(spec: &RootSpec) -> Result<()> {
240 for (name, node) in &spec.commands {
241 node.validate(name)?;
242 }
243 Ok(())
244}
245
246pub fn load_spec_from_path(spec_path: &Path, platform: HostPlatform) -> Result<RootSpec> {
247 let text = std::fs::read_to_string(spec_path)
248 .with_context(|| format!("read spec file {}", spec_path.display()))?;
249 let raw: RawRootSpec = serde_yaml::from_str(&text).context("parse YAML spec")?;
250 let include_base = spec_path
251 .parent()
252 .unwrap_or_else(|| Path::new("."))
253 .to_path_buf();
254 let raw = merge_root_includes(raw, &include_base)?;
255 let ctx = LoadCtx { include_base };
256 let mut spec = materialize_root(raw, &ctx)?;
257 validate_root(&spec)?;
260 filter_spec_for_platform(&mut spec, platform.id.as_ref());
261 Ok(spec)
262}
263
264pub fn load_spec_from_str(
267 raw: &str,
268 include_base: Option<&Path>,
269 platform: HostPlatform,
270) -> Result<RootSpec> {
271 let raw: RawRootSpec = serde_yaml::from_str(raw).context("parse YAML spec")?;
272 let raw = if let Some(base) = include_base {
273 merge_root_includes(raw, base)?
274 } else {
275 if !raw.include.is_empty() {
276 bail!("root-level `include` requires a base directory (pass when calling load_spec_from_str)");
277 }
278 raw
279 };
280 let ctx = LoadCtx {
281 include_base: include_base
282 .map(Path::to_path_buf)
283 .unwrap_or_else(|| PathBuf::from(".")),
284 };
285 let mut spec = materialize_root(raw, &ctx)?;
286 validate_root(&spec)?;
287 filter_spec_for_platform(&mut spec, platform.id.as_ref());
288 Ok(spec)
289}
290
291pub fn filter_spec_for_platform(spec: &mut RootSpec, platform_id: &str) {
292 filter_command_map(&mut spec.commands, platform_id);
293}
294
295fn filter_command_map(map: &mut BTreeMap<String, CommandNode>, platform_id: &str) {
296 map.retain(|_, node| {
297 if !node_visible_for_platform(&node.os, platform_id) {
298 return false;
299 }
300 filter_command_map(&mut node.commands, platform_id);
301 if node.exec.is_some() {
302 return true;
303 }
304 !node.commands.is_empty()
305 });
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311 use crate::ExecSpec;
312
313 #[test]
314 fn os_filter_drops_linux_only_branch() {
315 let mut spec = RootSpec {
316 metadata: None,
317 commands: BTreeMap::from([(
318 "sys".into(),
319 CommandNode {
320 os: vec!["linux".into()],
321 about: "linux".into(),
322 commands: BTreeMap::from([(
323 "ports".into(),
324 CommandNode {
325 exec: Some(ExecSpec {
326 argv: vec!["echo".into(), "x".into()],
327 passthrough: false,
328 }),
329 ..Default::default()
330 },
331 )]),
332 ..Default::default()
333 },
334 )]),
335 };
336 filter_spec_for_platform(&mut spec, "macos");
337 assert!(spec.commands.is_empty());
338 }
339}