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
54 .iter()
55 .any(|o| normalize_os_token(o) == platform)
56}
57
58#[derive(Debug, Deserialize)]
59struct RawRootSpec {
60 metadata: Option<Metadata>,
61 #[serde(default)]
62 include: Vec<String>,
63 #[serde(default)]
64 commands: BTreeMap<String, RawCommandNode>,
65}
66
67#[derive(Debug, Deserialize)]
68struct RawCommandNode {
69 #[serde(default)]
70 os: Vec<String>,
71 #[serde(default)]
72 about: String,
73 path: Option<String>,
74 #[serde(default)]
75 dependencies: Vec<String>,
76 #[serde(default)]
77 requires: Vec<String>,
78 #[serde(default)]
79 env: BTreeMap<String, String>,
80 include: Option<String>,
81 #[serde(default)]
82 commands: BTreeMap<String, RawCommandNode>,
83 exec: Option<ExecSpec>,
84}
85
86#[derive(Clone)]
87enum LoadCtx<'a> {
88 File {
89 include_base: PathBuf,
91 },
92 Embedded {
93 virtual_dir: String,
96 map: &'a [(&'static str, &'static str)],
97 },
98}
99
100impl<'a> LoadCtx<'a> {
101 fn read_include(&self, rel: &str) -> Result<String> {
102 let rel = rel.trim();
103 if rel.is_empty() {
104 bail!("empty include path");
105 }
106 match self {
107 LoadCtx::File { include_base } => {
108 let path = resolve_under(include_base, rel)?;
109 std::fs::read_to_string(&path)
110 .with_context(|| format!("read included spec {}", path.display()))
111 }
112 LoadCtx::Embedded { virtual_dir, map } => {
113 let key = embedded_slash_path(virtual_dir, rel);
114 map.iter()
115 .find(|(k, _)| *k == key.as_str())
116 .map(|(_, v)| (*v).to_string())
117 .with_context(|| format!("missing embedded include `{key}`"))
118 }
119 }
120 }
121
122 fn ctx_for_nested_include(&self, rel: &str) -> Result<LoadCtx<'a>> {
123 match self {
124 LoadCtx::File { include_base } => {
125 let path = resolve_under(include_base, rel)?;
126 Ok(LoadCtx::File {
127 include_base: path
128 .parent()
129 .unwrap_or_else(|| Path::new("."))
130 .to_path_buf(),
131 })
132 }
133 LoadCtx::Embedded { virtual_dir, map } => {
134 let _ = self.read_include(rel)?;
135 let key = embedded_slash_path(virtual_dir, rel);
136 let parent = Path::new(&key)
137 .parent()
138 .map(|p| p.to_string_lossy().replace('\\', "/"))
139 .filter(|s| !s.is_empty())
140 .unwrap_or_default();
141 Ok(LoadCtx::Embedded {
142 virtual_dir: parent,
143 map: *map,
144 })
145 }
146 }
147 }
148
149 fn visit_token(&self, rel: &str) -> Result<String> {
150 match self {
151 LoadCtx::File { include_base } => {
152 let p = resolve_under(include_base, rel)?;
153 Ok(p.to_string_lossy().to_string())
154 }
155 LoadCtx::Embedded { virtual_dir, .. } => Ok(embedded_slash_path(virtual_dir, rel)),
156 }
157 }
158}
159
160fn embedded_slash_path(virtual_dir: &str, rel: &str) -> String {
161 let rel = rel.trim();
162 let p = if Path::new(rel).is_absolute() {
163 PathBuf::from(rel)
164 } else {
165 Path::new(virtual_dir).join(rel)
166 };
167 p.components()
168 .filter(|c| !matches!(c, std::path::Component::ParentDir))
169 .collect::<PathBuf>()
170 .to_string_lossy()
171 .replace('\\', "/")
172 .trim_start_matches('/')
173 .to_string()
174}
175
176fn resolve_under(base_dir: &Path, rel: &str) -> Result<PathBuf> {
177 let p = Path::new(rel);
178 let full = if p.is_absolute() {
179 p.to_path_buf()
180 } else {
181 base_dir.join(p)
182 };
183 full.canonicalize()
184 .with_context(|| format!("include path not found: {}", full.display()))
185}
186
187fn merge_os_filters(outer: &[String], inner: &[String]) -> Result<Vec<String>> {
188 match (outer.is_empty(), inner.is_empty()) {
189 (true, true) => Ok(vec![]),
190 (true, false) => Ok(inner.to_vec()),
191 (false, true) => Ok(outer.to_vec()),
192 (false, false) => {
193 let merged: Vec<String> = outer
194 .iter()
195 .filter(|o| {
196 let n = normalize_os_token(o);
197 inner.iter().any(|i| normalize_os_token(i) == n)
198 })
199 .cloned()
200 .collect();
201 if merged.is_empty() {
202 bail!(
203 "conflicting `os:` filters between include wrapper and included file \
204 (no platform appears in both lists)"
205 );
206 }
207 Ok(merged)
208 }
209 }
210}
211
212fn overlay_about(overlay: &str, base: String) -> String {
213 let o = overlay.trim();
214 if o.is_empty() {
215 base
216 } else {
217 o.to_string()
218 }
219}
220
221fn resolve_raw_command_node(
222 raw: RawCommandNode,
223 ctx: &LoadCtx<'_>,
224 visited: &mut HashSet<String>,
225) -> Result<CommandNode> {
226 if raw.include.is_some() && (raw.exec.is_some() || !raw.commands.is_empty()) {
227 bail!("command with `include` cannot also define `exec` or nested `commands` in the same YAML map");
228 }
229
230 let mut raw = raw;
231 if let Some(rel) = raw.include.take() {
232 let token = ctx.visit_token(&rel)?;
233 if !visited.insert(token.clone()) {
234 bail!("include cycle detected at `{token}`");
235 }
236 let text = ctx.read_include(&rel)?;
237 let inner: RawCommandNode =
238 serde_yaml::from_str(&text).with_context(|| format!("parse include `{rel}`"))?;
239 let nested_ctx = ctx.ctx_for_nested_include(&rel)?;
240 let mut node = resolve_raw_command_node(inner, &nested_ctx, visited)?;
241 visited.remove(&token);
242 node.os = merge_os_filters(&raw.os, &node.os)?;
243 node.about = overlay_about(&raw.about, node.about);
244 return Ok(node);
245 }
246
247 let mut commands = BTreeMap::new();
248 for (name, child) in raw.commands {
249 commands.insert(name, resolve_raw_command_node(child, ctx, visited)?);
250 }
251
252 Ok(CommandNode {
253 os: raw.os,
254 about: raw.about,
255 path: raw.path,
256 dependencies: raw.dependencies,
257 requires: raw.requires,
258 env: raw.env,
259 commands,
260 exec: raw.exec,
261 })
262}
263
264fn merge_root_includes(mut root: RawRootSpec, include_base: &Path) -> Result<RawRootSpec> {
265 let base_dir = include_base;
266 let mut merged = BTreeMap::new();
267 for inc in &root.include {
268 let path = resolve_under(base_dir, inc)?;
269 let text = std::fs::read_to_string(&path)
270 .with_context(|| format!("read root include {}", path.display()))?;
271 let fragment: RawRootSpec =
272 serde_yaml::from_str(&text).with_context(|| format!("parse {}", path.display()))?;
273 let frag_base = path
274 .parent()
275 .unwrap_or_else(|| Path::new("."))
276 .to_path_buf();
277 let mut expanded = merge_root_includes(fragment, &frag_base)?;
278 merged.append(&mut expanded.commands);
279 }
280 merged.append(&mut root.commands);
281 root.commands = merged;
282 root.include.clear();
283 Ok(root)
284}
285
286fn merge_root_includes_embedded(
287 mut root: RawRootSpec,
288 virtual_dir: &str,
289 map: &[(&'static str, &'static str)],
290) -> Result<RawRootSpec> {
291 let ctx = LoadCtx::Embedded {
292 virtual_dir: virtual_dir.to_string(),
293 map,
294 };
295 let mut merged = BTreeMap::new();
296 for inc in &root.include {
297 let text = ctx
298 .read_include(inc)
299 .with_context(|| format!("read embedded root include `{inc}`"))?;
300 let fragment: RawRootSpec = serde_yaml::from_str(&text)
301 .with_context(|| format!("parse embedded root include `{inc}`"))?;
302 let child_vdir = Path::new(virtual_dir)
303 .join(inc)
304 .parent()
305 .map(|p| p.to_string_lossy().replace('\\', "/"))
306 .filter(|s| !s.is_empty())
307 .unwrap_or_default();
308 let mut expanded = merge_root_includes_embedded(fragment, &child_vdir, map)?;
309 merged.append(&mut expanded.commands);
310 }
311 merged.append(&mut root.commands);
312 root.commands = merged;
313 root.include.clear();
314 Ok(root)
315}
316
317fn materialize_root(raw: RawRootSpec, ctx: &LoadCtx<'_>) -> Result<RootSpec> {
318 let mut visited = HashSet::new();
319 let mut commands = BTreeMap::new();
320 for (name, node) in raw.commands {
321 commands.insert(name, resolve_raw_command_node(node, ctx, &mut visited)?);
322 }
323 Ok(RootSpec {
324 metadata: raw.metadata,
325 commands,
326 })
327}
328
329fn validate_root(spec: &RootSpec) -> Result<()> {
330 for (name, node) in &spec.commands {
331 node.validate(name)?;
332 }
333 Ok(())
334}
335
336pub fn load_spec_from_path(spec_path: &Path, platform: HostPlatform) -> Result<RootSpec> {
337 let text = std::fs::read_to_string(spec_path)
338 .with_context(|| format!("read spec file {}", spec_path.display()))?;
339 let raw: RawRootSpec = serde_yaml::from_str(&text).context("parse YAML spec")?;
340 let include_base = spec_path
341 .parent()
342 .unwrap_or_else(|| Path::new("."))
343 .to_path_buf();
344 let raw = merge_root_includes(raw, &include_base)?;
345 let ctx = LoadCtx::File { include_base };
346 let mut spec = materialize_root(raw, &ctx)?;
347 validate_root(&spec)?;
350 filter_spec_for_platform(&mut spec, platform.id.as_ref());
351 Ok(spec)
352}
353
354pub fn load_spec_from_str_with_embedded(
355 raw: &str,
356 virtual_dir: &str,
357 embedded: &[(&'static str, &'static str)],
358 platform: HostPlatform,
359) -> Result<RootSpec> {
360 let mut raw_spec: RawRootSpec = serde_yaml::from_str(raw).context("parse YAML spec")?;
361 raw_spec = merge_root_includes_embedded(raw_spec, virtual_dir, embedded)?;
362 let ctx = LoadCtx::Embedded {
363 virtual_dir: virtual_dir.to_string(),
364 map: embedded,
365 };
366 let mut spec = materialize_root(raw_spec, &ctx)?;
367 validate_root(&spec)?;
368 filter_spec_for_platform(&mut spec, platform.id.as_ref());
369 Ok(spec)
370}
371
372pub fn load_spec_from_str(raw: &str, include_base: Option<&Path>, platform: HostPlatform) -> Result<RootSpec> {
375 let raw: RawRootSpec = serde_yaml::from_str(raw).context("parse YAML spec")?;
376 let raw = if let Some(base) = include_base {
377 merge_root_includes(raw, base)?
378 } else {
379 if !raw.include.is_empty() {
380 bail!("root-level `include` requires a base directory (pass when calling load_spec_from_str)");
381 }
382 raw
383 };
384 let ctx = LoadCtx::File {
385 include_base: include_base
386 .map(Path::to_path_buf)
387 .unwrap_or_else(|| PathBuf::from(".")),
388 };
389 let mut spec = materialize_root(raw, &ctx)?;
390 validate_root(&spec)?;
391 filter_spec_for_platform(&mut spec, platform.id.as_ref());
392 Ok(spec)
393}
394
395pub fn filter_spec_for_platform(spec: &mut RootSpec, platform_id: &str) {
396 filter_command_map(&mut spec.commands, platform_id);
397}
398
399fn filter_command_map(map: &mut BTreeMap<String, CommandNode>, platform_id: &str) {
400 map.retain(|_, node| {
401 if !node_visible_for_platform(&node.os, platform_id) {
402 return false;
403 }
404 filter_command_map(&mut node.commands, platform_id);
405 if node.exec.is_some() {
406 return true;
407 }
408 !node.commands.is_empty()
409 });
410}
411
412#[cfg(test)]
413mod tests {
414 use super::*;
415 use crate::ExecSpec;
416
417 #[test]
418 fn os_filter_drops_linux_only_branch() {
419 let mut spec = RootSpec {
420 metadata: None,
421 commands: BTreeMap::from([(
422 "sys".into(),
423 CommandNode {
424 os: vec!["linux".into()],
425 about: "linux".into(),
426 commands: BTreeMap::from([(
427 "ports".into(),
428 CommandNode {
429 exec: Some(ExecSpec {
430 argv: vec!["echo".into(), "x".into()],
431 passthrough: false,
432 }),
433 ..Default::default()
434 },
435 )]),
436 ..Default::default()
437 },
438 )]),
439 };
440 filter_spec_for_platform(&mut spec, "macos");
441 assert!(spec.commands.is_empty());
442 }
443}