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
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        /// Directory used to resolve relative `include` paths (typically the YAML file's parent).
90        include_base: PathBuf,
91    },
92    Embedded {
93        /// Directory of the *current* virtual YAML file, relative to the crate root (`jan-cli/`).
94        /// Empty string means the directory containing `default.spec.yaml`.
95        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 structure before OS filtering: filtering can drop nested nodes (e.g. empty
348    // placeholders) and would otherwise hide invalid `exec` + `commands` combinations.
349    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
372/// Parse an in-memory spec. Root-level `include` entries resolve relative to `include_base`.
373/// Pass `None` only when the document has no root `include` keys.
374pub 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}