Skip to main content

jan_cli/
spec_load.rs

1//! YAML spec loading with `include` links and host-OS filtering.
2
3use 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/// Which platform string to use when filtering `os:` lists on commands.
13#[derive(Clone, Debug, PartialEq, Eq)]
14pub struct HostPlatform {
15    /// Normalized id: `linux`, `macos`, `windows`, or another `std::env::consts::OS` value.
16    pub id: Cow<'static, str>,
17}
18
19impl HostPlatform {
20    /// Resolve the process host, honoring `JAN_OS` when set (for tests and overrides).
21    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    /// Directory used to resolve relative `include` paths (typically the YAML file's parent).
87    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 structure before OS filtering: filtering can drop nested nodes (e.g. empty
258    // placeholders) and would otherwise hide invalid `exec` + `commands` combinations.
259    validate_root(&spec)?;
260    filter_spec_for_platform(&mut spec, platform.id.as_ref());
261    Ok(spec)
262}
263
264/// Parse an in-memory spec. Root-level `include` entries resolve relative to `include_base`.
265/// Pass `None` only when the document has no root `include` keys.
266pub 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}