Skip to main content

cloud/
app_manifest.rs

1//! `yah-app.toml` schema, parser, closed-set `required_when` evaluator,
2//! `.yah/apps.toml` registry, and workspace discovery (R470-T7).
3//!
4//! **Two-tier model (W193):** assets are declared by services in
5//! `workload.toml`; apps declare which aliases they consume here. The two
6//! files live independently so external apps that only *consume* content-
7//! addressed assets don't need a `service.toml`.
8//!
9//! # `yah-app.toml` example
10//!
11//! ```toml
12//! schema_version = 1
13//! name = "yah-desktop"
14//!
15//! [[asset_dep]]
16//! alias = "whisper-default-coreml"
17//! required_when = "target_os == \"macos\""
18//! purpose = "Local dictation (WhisperKit ANE path)"
19//!
20//! [[asset_dep]]
21//! alias = "whisper-default-ggml"
22//! required_when = "target_os != \"macos\""
23//! purpose = "Local dictation (whisper.cpp ggml path)"
24//! ```
25
26use std::collections::{HashMap, HashSet};
27use std::path::{Path, PathBuf};
28
29use anyhow::{bail, Context, Result};
30use serde::de::{self, Visitor};
31use serde::{Deserialize, Deserializer, Serialize, Serializer};
32
33// ── Predicate DSL ──────────────────────────────────────────────────────────
34
35/// Comparison operator for `target_os` and `target_arch` predicates.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum CmpOp {
38    Eq,
39    Ne,
40}
41
42/// A parsed `required_when` predicate — eagerly validated at load time.
43///
44/// Closed predicate set — no user-defined extensions:
45/// - `target_os == "value"` / `target_os != "value"`
46/// - `target_arch == "value"` / `target_arch != "value"`
47/// - `feature("name")` — feature flag enabled in [`HostContext`]
48/// - `env("VAR")` — environment variable `VAR` is set and non-empty
49/// - `!pred`, `pred && pred`, `pred || pred`, `(pred)`
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum Predicate {
52    TargetOs(CmpOp, String),
53    TargetArch(CmpOp, String),
54    Feature(String),
55    Env(String),
56    Not(Box<Predicate>),
57    And(Box<Predicate>, Box<Predicate>),
58    Or(Box<Predicate>, Box<Predicate>),
59}
60
61impl Predicate {
62    /// Evaluate this predicate against a host context.
63    pub fn evaluate(&self, ctx: &HostContext) -> bool {
64        match self {
65            Predicate::TargetOs(CmpOp::Eq, v) => ctx.target_os == *v,
66            Predicate::TargetOs(CmpOp::Ne, v) => ctx.target_os != *v,
67            Predicate::TargetArch(CmpOp::Eq, v) => ctx.target_arch == *v,
68            Predicate::TargetArch(CmpOp::Ne, v) => ctx.target_arch != *v,
69            Predicate::Feature(f) => ctx.features.contains(f.as_str()),
70            Predicate::Env(var) => ctx.env.get(var.as_str()).is_some_and(|v| !v.is_empty()),
71            Predicate::Not(inner) => !inner.evaluate(ctx),
72            Predicate::And(a, b) => a.evaluate(ctx) && b.evaluate(ctx),
73            Predicate::Or(a, b) => a.evaluate(ctx) || b.evaluate(ctx),
74        }
75    }
76}
77
78/// Runtime host context for evaluating [`Predicate`]s.
79#[derive(Debug, Clone)]
80pub struct HostContext {
81    /// OS name (e.g. `"macos"`, `"linux"`, `"windows"`).
82    pub target_os: String,
83    /// CPU architecture (e.g. `"aarch64"`, `"x86_64"`).
84    pub target_arch: String,
85    /// Enabled feature flags (app-level capability gates).
86    pub features: HashSet<String>,
87    /// Environment variables to check for `env("VAR")` predicates.
88    pub env: HashMap<String, String>,
89}
90
91impl HostContext {
92    /// Build a context from the current process environment and compile-time
93    /// OS / arch constants. `features` and extra env vars may be injected for
94    /// testing or for app-level capability gates.
95    pub fn current() -> Self {
96        Self {
97            target_os: std::env::consts::OS.to_string(),
98            target_arch: std::env::consts::ARCH.to_string(),
99            features: HashSet::new(),
100            env: std::env::vars().collect(),
101        }
102    }
103}
104
105// ── RequiredWhen — eagerly-parsed serde newtype ────────────────────────────
106
107/// A `required_when` value: the raw TOML string paired with its parsed
108/// [`Predicate`]. Serialises back to the original string; parse errors surface
109/// at deserialization time so typos never silently-skip a row.
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct RequiredWhen {
112    pub raw: String,
113    pub predicate: Predicate,
114}
115
116impl RequiredWhen {
117    pub fn parse(s: &str) -> Result<Self> {
118        let predicate =
119            parse_predicate(s).with_context(|| format!("in required_when = \"{s}\""))?;
120        Ok(Self {
121            raw: s.to_string(),
122            predicate,
123        })
124    }
125
126    pub fn evaluate(&self, ctx: &HostContext) -> bool {
127        self.predicate.evaluate(ctx)
128    }
129}
130
131impl Serialize for RequiredWhen {
132    fn serialize<S: Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
133        s.serialize_str(&self.raw)
134    }
135}
136
137impl<'de> Deserialize<'de> for RequiredWhen {
138    fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
139        struct V;
140        impl Visitor<'_> for V {
141            type Value = RequiredWhen;
142            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143                f.write_str("a required_when predicate string")
144            }
145            fn visit_str<E: de::Error>(self, v: &str) -> std::result::Result<RequiredWhen, E> {
146                // Use {e:#} to include the full anyhow error chain in the serde message.
147                RequiredWhen::parse(v).map_err(|e| de::Error::custom(format!("{e:#}")))
148            }
149        }
150        d.deserialize_str(V)
151    }
152}
153
154// ── Predicate parser ───────────────────────────────────────────────────────
155
156fn parse_predicate(input: &str) -> Result<Predicate> {
157    let mut p = Parser::new(input);
158    let pred = p.parse_or()?;
159    p.expect_eof()?;
160    Ok(pred)
161}
162
163#[derive(Debug, Clone, PartialEq, Eq)]
164enum Token {
165    Ident(String),
166    Str(String),
167    EqEq,
168    BangEq,
169    And,
170    Or,
171    Bang,
172    LParen,
173    RParen,
174    Eof,
175}
176
177struct Lexer<'a> {
178    src: &'a [u8],
179    pos: usize,
180}
181
182impl<'a> Lexer<'a> {
183    fn new(src: &'a str) -> Self {
184        Self {
185            src: src.as_bytes(),
186            pos: 0,
187        }
188    }
189
190    fn skip_ws(&mut self) {
191        while self.pos < self.src.len() && self.src[self.pos].is_ascii_whitespace() {
192            self.pos += 1;
193        }
194    }
195
196    fn next(&mut self) -> Result<Token> {
197        self.skip_ws();
198        if self.pos >= self.src.len() {
199            return Ok(Token::Eof);
200        }
201        match self.src[self.pos] {
202            b'=' if self.src.get(self.pos + 1) == Some(&b'=') => {
203                self.pos += 2;
204                Ok(Token::EqEq)
205            }
206            b'!' if self.src.get(self.pos + 1) == Some(&b'=') => {
207                self.pos += 2;
208                Ok(Token::BangEq)
209            }
210            b'&' if self.src.get(self.pos + 1) == Some(&b'&') => {
211                self.pos += 2;
212                Ok(Token::And)
213            }
214            b'|' if self.src.get(self.pos + 1) == Some(&b'|') => {
215                self.pos += 2;
216                Ok(Token::Or)
217            }
218            b'!' => {
219                self.pos += 1;
220                Ok(Token::Bang)
221            }
222            b'(' => {
223                self.pos += 1;
224                Ok(Token::LParen)
225            }
226            b')' => {
227                self.pos += 1;
228                Ok(Token::RParen)
229            }
230            b'"' => {
231                self.pos += 1;
232                let start = self.pos;
233                while self.pos < self.src.len() && self.src[self.pos] != b'"' {
234                    if self.src[self.pos] == b'\\' {
235                        self.pos += 1; // skip escaped char
236                    }
237                    self.pos += 1;
238                }
239                if self.pos >= self.src.len() {
240                    bail!("unterminated string literal");
241                }
242                let s = std::str::from_utf8(&self.src[start..self.pos])
243                    .context("invalid UTF-8 in string literal")?
244                    .to_string();
245                self.pos += 1; // consume closing "
246                Ok(Token::Str(s))
247            }
248            c if c.is_ascii_alphabetic() || c == b'_' => {
249                let start = self.pos;
250                while self.pos < self.src.len()
251                    && (self.src[self.pos].is_ascii_alphanumeric() || self.src[self.pos] == b'_')
252                {
253                    self.pos += 1;
254                }
255                let ident = std::str::from_utf8(&self.src[start..self.pos])
256                    .context("invalid UTF-8 in identifier")?
257                    .to_string();
258                Ok(Token::Ident(ident))
259            }
260            c => bail!("unexpected character '{}'", c as char),
261        }
262    }
263
264    fn peek(&mut self) -> Result<Token> {
265        let saved = self.pos;
266        let tok = self.next()?;
267        self.pos = saved;
268        Ok(tok)
269    }
270}
271
272struct Parser<'a> {
273    lex: Lexer<'a>,
274}
275
276impl<'a> Parser<'a> {
277    fn new(input: &'a str) -> Self {
278        Self {
279            lex: Lexer::new(input),
280        }
281    }
282
283    fn expect_eof(&mut self) -> Result<()> {
284        match self.lex.next()? {
285            Token::Eof => Ok(()),
286            tok => bail!("unexpected token {tok:?} at end of predicate"),
287        }
288    }
289
290    // or_expr = and_expr ('||' and_expr)*
291    fn parse_or(&mut self) -> Result<Predicate> {
292        let mut lhs = self.parse_and()?;
293        while self.lex.peek()? == Token::Or {
294            self.lex.next()?;
295            let rhs = self.parse_and()?;
296            lhs = Predicate::Or(Box::new(lhs), Box::new(rhs));
297        }
298        Ok(lhs)
299    }
300
301    // and_expr = not_expr ('&&' not_expr)*
302    fn parse_and(&mut self) -> Result<Predicate> {
303        let mut lhs = self.parse_not()?;
304        while self.lex.peek()? == Token::And {
305            self.lex.next()?;
306            let rhs = self.parse_not()?;
307            lhs = Predicate::And(Box::new(lhs), Box::new(rhs));
308        }
309        Ok(lhs)
310    }
311
312    // not_expr = '!' not_expr | atom
313    fn parse_not(&mut self) -> Result<Predicate> {
314        if self.lex.peek()? == Token::Bang {
315            self.lex.next()?;
316            Ok(Predicate::Not(Box::new(self.parse_not()?)))
317        } else {
318            self.parse_atom()
319        }
320    }
321
322    // atom = '(' predicate ')' | target_os_pred | target_arch_pred | feature_pred | env_pred
323    fn parse_atom(&mut self) -> Result<Predicate> {
324        match self.lex.next()? {
325            Token::LParen => {
326                let inner = self.parse_or()?;
327                match self.lex.next()? {
328                    Token::RParen => Ok(inner),
329                    tok => bail!("expected ')' but got {tok:?}"),
330                }
331            }
332            Token::Ident(name) => match name.as_str() {
333                "target_os" => {
334                    let op = self.parse_cmp_op()?;
335                    let val = self.expect_str()?;
336                    Ok(Predicate::TargetOs(op, val))
337                }
338                "target_arch" => {
339                    let op = self.parse_cmp_op()?;
340                    let val = self.expect_str()?;
341                    Ok(Predicate::TargetArch(op, val))
342                }
343                "feature" => {
344                    self.expect_lparen()?;
345                    let name = self.expect_str()?;
346                    self.expect_rparen()?;
347                    Ok(Predicate::Feature(name))
348                }
349                "env" => {
350                    self.expect_lparen()?;
351                    let var = self.expect_str()?;
352                    self.expect_rparen()?;
353                    Ok(Predicate::Env(var))
354                }
355                other => bail!(
356                    "unknown predicate '{other}' — valid atoms are: \
357                     target_os, target_arch, feature, env"
358                ),
359            },
360            tok => bail!("expected predicate atom, got {tok:?}"),
361        }
362    }
363
364    fn parse_cmp_op(&mut self) -> Result<CmpOp> {
365        match self.lex.next()? {
366            Token::EqEq => Ok(CmpOp::Eq),
367            Token::BangEq => Ok(CmpOp::Ne),
368            tok => bail!("expected '==' or '!=' but got {tok:?}"),
369        }
370    }
371
372    fn expect_str(&mut self) -> Result<String> {
373        match self.lex.next()? {
374            Token::Str(s) => Ok(s),
375            tok => bail!("expected a quoted string but got {tok:?}"),
376        }
377    }
378
379    fn expect_lparen(&mut self) -> Result<()> {
380        match self.lex.next()? {
381            Token::LParen => Ok(()),
382            tok => bail!("expected '(' but got {tok:?}"),
383        }
384    }
385
386    fn expect_rparen(&mut self) -> Result<()> {
387        match self.lex.next()? {
388            Token::RParen => Ok(()),
389            tok => bail!("expected ')' but got {tok:?}"),
390        }
391    }
392}
393
394// ── AppManifest (yah-app.toml) ─────────────────────────────────────────────
395
396/// One declared dependency row in a `yah-app.toml`.
397#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
398pub struct AssetDep {
399    /// Logical alias name, workspace-globally unique (e.g. `whisper-default-coreml`).
400    pub alias: String,
401    /// Optional guard evaluated against the current host. When absent the dep
402    /// is always required.
403    #[serde(skip_serializing_if = "Option::is_none")]
404    pub required_when: Option<RequiredWhen>,
405    /// Human-readable description shown in the status panel.
406    #[serde(skip_serializing_if = "Option::is_none")]
407    pub purpose: Option<String>,
408}
409
410impl AssetDep {
411    /// Whether this dep is required on `ctx`. Absent `required_when` → always required.
412    pub fn required_here(&self, ctx: &HostContext) -> bool {
413        self.required_when
414            .as_ref()
415            .map_or(true, |rw| rw.evaluate(ctx))
416    }
417}
418
419/// Parsed `yah-app.toml` — consumer-side declaration of alias dependencies.
420///
421/// File lives at `<app-root>/yah-app.toml`, sibling to `Cargo.toml` or
422/// `package.json`. Discovered via `.yah/apps.toml` registry or a
423/// `find`-style walk when the registry is absent.
424#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
425pub struct AppManifest {
426    pub schema_version: u32,
427    pub name: String,
428    #[serde(default, rename = "asset_dep")]
429    pub asset_deps: Vec<AssetDep>,
430}
431
432impl AppManifest {
433    /// Load `<app_root>/yah-app.toml`. Returns `Err` if the file is missing
434    /// or malformed — including any invalid `required_when` expression.
435    pub fn load(app_root: &Path) -> Result<Self> {
436        let path = app_root.join("yah-app.toml");
437        let src = std::fs::read_to_string(&path)
438            .with_context(|| format!("reading {}", path.display()))?;
439        toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))
440    }
441
442    /// Save this manifest to `<app_root>/yah-app.toml`.
443    pub fn save(&self, app_root: &Path) -> Result<()> {
444        let path = app_root.join("yah-app.toml");
445        let src = toml::to_string_pretty(self).context("serializing AppManifest")?;
446        std::fs::write(&path, src).with_context(|| format!("writing {}", path.display()))
447    }
448}
449
450// ── AppsRegistry (.yah/apps.toml) ─────────────────────────────────────────
451
452/// One entry in `.yah/apps.toml`.
453#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
454pub struct AppRegistryEntry {
455    pub name: String,
456    /// Path to the app root, relative to the workspace root.
457    pub path: PathBuf,
458}
459
460/// `.yah/apps.toml` — registry of app roots for fast discovery.
461///
462/// `yah cloud apps add <path>` appends an entry; `yah cloud apps scan`
463/// auto-discovers `yah-app.toml` files up to `maxdepth=4`. When the
464/// registry file doesn't exist, [`AppsRegistry::discover`] falls back to
465/// the walk automatically.
466#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
467pub struct AppsRegistry {
468    #[serde(default, rename = "apps")]
469    pub entries: Vec<AppRegistryEntry>,
470}
471
472impl AppsRegistry {
473    /// Load from `<workspace>/.yah/apps.toml`. Returns an empty registry
474    /// when the file doesn't exist — not an error.
475    pub fn load(workspace_root: &Path) -> Result<Self> {
476        let path = crate::paths::apps_registry(workspace_root);
477        match std::fs::read_to_string(&path) {
478            Ok(src) => toml::from_str(&src).with_context(|| format!("parsing {}", path.display())),
479            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
480            Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
481        }
482    }
483
484    /// Save to `<workspace>/.yah/apps.toml`, creating the `.yah/` dir if needed.
485    pub fn save(&self, workspace_root: &Path) -> Result<()> {
486        let path = crate::paths::apps_registry(workspace_root);
487        if let Some(parent) = path.parent() {
488            std::fs::create_dir_all(parent)
489                .with_context(|| format!("creating {}", parent.display()))?;
490        }
491        let src = toml::to_string_pretty(self).context("serializing AppsRegistry")?;
492        std::fs::write(&path, src).with_context(|| format!("writing {}", path.display()))
493    }
494
495    /// Add an entry. Does not write to disk — call [`save`](Self::save) after.
496    pub fn add(&mut self, name: impl Into<String>, path: impl Into<PathBuf>) {
497        let name = name.into();
498        let path = path.into();
499        if let Some(e) = self.entries.iter_mut().find(|e| e.name == name) {
500            e.path = path;
501        } else {
502            self.entries.push(AppRegistryEntry { name, path });
503        }
504    }
505
506    /// Remove by name. Returns `true` if an entry was removed.
507    pub fn remove(&mut self, name: &str) -> bool {
508        let before = self.entries.len();
509        self.entries.retain(|e| e.name != name);
510        self.entries.len() < before
511    }
512}
513
514// ── Discovery ─────────────────────────────────────────────────────────────
515
516/// Discover all `yah-app.toml` files in the workspace.
517///
518/// Uses `.yah/apps.toml` when present; falls back to a `find`-style walk
519/// with `maxdepth = 4` for zero-config single-app projects.
520pub fn discover_app_manifests(workspace_root: &Path) -> Result<Vec<(PathBuf, AppManifest)>> {
521    let registry = AppsRegistry::load(workspace_root)?;
522    if !registry.entries.is_empty() {
523        let mut results = Vec::new();
524        for entry in &registry.entries {
525            let app_root = workspace_root.join(&entry.path);
526            match AppManifest::load(&app_root) {
527                Ok(manifest) => results.push((app_root, manifest)),
528                Err(e) => {
529                    tracing::warn!(
530                        name = %entry.name,
531                        path = %entry.path.display(),
532                        error = %e,
533                        "skipping app manifest that failed to load"
534                    );
535                }
536            }
537        }
538        return Ok(results);
539    }
540
541    // Fallback: walk the workspace tree up to maxdepth=4.
542    find_yah_app_tomls(workspace_root, 4).map(|paths| {
543        paths
544            .into_iter()
545            .filter_map(|app_root| {
546                AppManifest::load(&app_root)
547                    .map(|m| (app_root, m))
548                    .map_err(|e| {
549                        tracing::debug!(error = %e, "skipping unparseable yah-app.toml");
550                        e
551                    })
552                    .ok()
553            })
554            .collect()
555    })
556}
557
558/// Walk `root` up to `max_depth` directory levels and return the *parent
559/// directories* of every `yah-app.toml` found (i.e. the app roots).
560///
561/// Skips hidden directories (`.git`, `.yah`, `target`, `node_modules`).
562pub fn find_yah_app_tomls(root: &Path, max_depth: usize) -> Result<Vec<PathBuf>> {
563    let mut results = Vec::new();
564    walk_for_yah_app(root, 0, max_depth, &mut results)?;
565    Ok(results)
566}
567
568fn walk_for_yah_app(
569    dir: &Path,
570    depth: usize,
571    max_depth: usize,
572    out: &mut Vec<PathBuf>,
573) -> Result<()> {
574    if dir.join("yah-app.toml").is_file() {
575        out.push(dir.to_path_buf());
576    }
577    if depth >= max_depth {
578        return Ok(());
579    }
580    let entries = match std::fs::read_dir(dir) {
581        Ok(e) => e,
582        Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => return Ok(()),
583        Err(e) => return Err(e).with_context(|| format!("reading dir {}", dir.display())),
584    };
585    for entry in entries.flatten() {
586        let path = entry.path();
587        if !path.is_dir() {
588            continue;
589        }
590        let name = entry.file_name();
591        let name = name.to_string_lossy();
592        // Skip common noise directories.
593        if matches!(
594            name.as_ref(),
595            ".git" | ".yah" | "target" | "node_modules" | ".build" | ".cache"
596        ) {
597            continue;
598        }
599        walk_for_yah_app(&path, depth + 1, max_depth, out)?;
600    }
601    Ok(())
602}
603
604// ── Tests ──────────────────────────────────────────────────────────────────
605
606#[cfg(test)]
607mod tests {
608    use super::*;
609    use tempfile::tempdir;
610
611    fn macos_ctx() -> HostContext {
612        HostContext {
613            target_os: "macos".into(),
614            target_arch: "aarch64".into(),
615            features: HashSet::new(),
616            env: HashMap::new(),
617        }
618    }
619
620    fn linux_ctx() -> HostContext {
621        HostContext {
622            target_os: "linux".into(),
623            target_arch: "x86_64".into(),
624            features: HashSet::new(),
625            env: HashMap::new(),
626        }
627    }
628
629    // ── Predicate parsing ──────────────────────────────────────────────────
630
631    #[test]
632    fn app_manifest_parse_target_os_eq() {
633        let p = parse_predicate(r#"target_os == "macos""#).unwrap();
634        assert_eq!(p, Predicate::TargetOs(CmpOp::Eq, "macos".into()));
635        assert!(p.evaluate(&macos_ctx()));
636        assert!(!p.evaluate(&linux_ctx()));
637    }
638
639    #[test]
640    fn app_manifest_parse_target_os_ne() {
641        let p = parse_predicate(r#"target_os != "macos""#).unwrap();
642        assert_eq!(p, Predicate::TargetOs(CmpOp::Ne, "macos".into()));
643        assert!(!p.evaluate(&macos_ctx()));
644        assert!(p.evaluate(&linux_ctx()));
645    }
646
647    #[test]
648    fn app_manifest_parse_target_arch() {
649        let p = parse_predicate(r#"target_arch == "aarch64""#).unwrap();
650        assert!(p.evaluate(&macos_ctx()));
651        assert!(!p.evaluate(&linux_ctx()));
652    }
653
654    #[test]
655    fn app_manifest_parse_feature() {
656        let p = parse_predicate(r#"feature("mlx")"#).unwrap();
657        assert_eq!(p, Predicate::Feature("mlx".into()));
658        let mut ctx = macos_ctx();
659        assert!(!p.evaluate(&ctx));
660        ctx.features.insert("mlx".into());
661        assert!(p.evaluate(&ctx));
662    }
663
664    #[test]
665    fn app_manifest_parse_env() {
666        let p = parse_predicate(r#"env("CI")"#).unwrap();
667        assert_eq!(p, Predicate::Env("CI".into()));
668        let mut ctx = macos_ctx();
669        assert!(!p.evaluate(&ctx));
670        ctx.env.insert("CI".into(), "true".into());
671        assert!(p.evaluate(&ctx));
672        // Empty value = not set.
673        ctx.env.insert("CI".into(), "".into());
674        assert!(!p.evaluate(&ctx));
675    }
676
677    #[test]
678    fn app_manifest_parse_not() {
679        let p = parse_predicate(r#"!target_os == "windows""#).unwrap();
680        assert!(p.evaluate(&macos_ctx()));
681        assert!(p.evaluate(&linux_ctx()));
682        let windows = HostContext {
683            target_os: "windows".into(),
684            target_arch: "x86_64".into(),
685            features: HashSet::new(),
686            env: HashMap::new(),
687        };
688        assert!(!p.evaluate(&windows));
689    }
690
691    #[test]
692    fn app_manifest_parse_and() {
693        let p = parse_predicate(r#"target_os == "macos" && target_arch == "aarch64""#).unwrap();
694        assert!(p.evaluate(&macos_ctx()));
695        let x86_mac = HostContext {
696            target_arch: "x86_64".into(),
697            ..macos_ctx()
698        };
699        assert!(!p.evaluate(&x86_mac));
700        assert!(!p.evaluate(&linux_ctx()));
701    }
702
703    #[test]
704    fn app_manifest_parse_or() {
705        let p = parse_predicate(r#"target_os == "macos" || target_os == "linux""#).unwrap();
706        assert!(p.evaluate(&macos_ctx()));
707        assert!(p.evaluate(&linux_ctx()));
708        let windows = HostContext {
709            target_os: "windows".into(),
710            ..linux_ctx()
711        };
712        assert!(!p.evaluate(&windows));
713    }
714
715    #[test]
716    fn app_manifest_parse_grouped() {
717        let p = parse_predicate(
718            r#"(target_os == "macos" || target_os == "linux") && target_arch == "aarch64""#,
719        )
720        .unwrap();
721        assert!(p.evaluate(&macos_ctx())); // macos + aarch64
722        assert!(!p.evaluate(&linux_ctx())); // linux + x86_64
723        let arm_linux = HostContext {
724            target_arch: "aarch64".into(),
725            ..linux_ctx()
726        };
727        assert!(p.evaluate(&arm_linux));
728    }
729
730    #[test]
731    fn app_manifest_unknown_predicate_errors() {
732        let err = parse_predicate(r#"cpu == "arm""#).unwrap_err();
733        assert!(err.to_string().contains("unknown predicate 'cpu'"), "{err}");
734    }
735
736    #[test]
737    fn app_manifest_unterminated_string_errors() {
738        let err = parse_predicate(r#"target_os == "macos"#).unwrap_err();
739        assert!(err.to_string().contains("unterminated"), "{err}");
740    }
741
742    // ── RequiredWhen serde ─────────────────────────────────────────────────
743
744    #[test]
745    fn app_manifest_required_when_round_trips_via_toml() {
746        #[derive(Serialize, Deserialize)]
747        struct Wrapper {
748            required_when: RequiredWhen,
749        }
750        let src = r#"required_when = 'target_os == "macos"'"#;
751        let w: Wrapper = toml::from_str(src).unwrap();
752        assert_eq!(w.required_when.raw, r#"target_os == "macos""#);
753        assert!(w.required_when.evaluate(&macos_ctx()));
754        assert!(!w.required_when.evaluate(&linux_ctx()));
755
756        let back = toml::to_string_pretty(&w).unwrap();
757        let w2: Wrapper = toml::from_str(&back).unwrap();
758        assert_eq!(w2.required_when, w.required_when);
759    }
760
761    #[test]
762    fn app_manifest_invalid_required_when_rejected_at_parse() {
763        #[derive(Debug, Deserialize)]
764        struct Wrapper {
765            required_when: RequiredWhen,
766        }
767        let src = r#"required_when = 'cpu == "arm"'"#;
768        let err = toml::from_str::<Wrapper>(src).unwrap_err();
769        assert!(err.to_string().contains("unknown predicate 'cpu'"), "{err}");
770    }
771
772    // ── AppManifest load/save ──────────────────────────────────────────────
773
774    #[test]
775    fn app_manifest_load_save_round_trip() {
776        let dir = tempdir().unwrap();
777        let manifest = AppManifest {
778            schema_version: 1,
779            name: "yah-desktop".into(),
780            asset_deps: vec![
781                AssetDep {
782                    alias: "whisper-default-coreml".into(),
783                    required_when: Some(RequiredWhen::parse(r#"target_os == "macos""#).unwrap()),
784                    purpose: Some("Local dictation (WhisperKit ANE path)".into()),
785                },
786                AssetDep {
787                    alias: "whisper-default-ggml".into(),
788                    required_when: Some(RequiredWhen::parse(r#"target_os != "macos""#).unwrap()),
789                    purpose: None,
790                },
791            ],
792        };
793
794        manifest.save(dir.path()).unwrap();
795        let loaded = AppManifest::load(dir.path()).unwrap();
796        assert_eq!(loaded, manifest);
797    }
798
799    #[test]
800    fn app_manifest_required_here_respects_predicate() {
801        let dep = AssetDep {
802            alias: "whisper-default-coreml".into(),
803            required_when: Some(RequiredWhen::parse(r#"target_os == "macos""#).unwrap()),
804            purpose: None,
805        };
806        assert!(dep.required_here(&macos_ctx()));
807        assert!(!dep.required_here(&linux_ctx()));
808    }
809
810    #[test]
811    fn app_manifest_no_required_when_always_required() {
812        let dep = AssetDep {
813            alias: "shared-model".into(),
814            required_when: None,
815            purpose: None,
816        };
817        assert!(dep.required_here(&macos_ctx()));
818        assert!(dep.required_here(&linux_ctx()));
819    }
820
821    // ── AppsRegistry ──────────────────────────────────────────────────────
822
823    #[test]
824    fn apps_registry_load_returns_empty_when_missing() {
825        let dir = tempdir().unwrap();
826        let reg = AppsRegistry::load(dir.path()).unwrap();
827        assert!(reg.entries.is_empty());
828    }
829
830    #[test]
831    fn apps_registry_add_save_load_round_trip() {
832        let dir = tempdir().unwrap();
833        std::fs::create_dir_all(dir.path().join(".yah")).unwrap();
834
835        let mut reg = AppsRegistry::default();
836        reg.add("yah-desktop", "app/yah/desktop");
837        reg.add("yah-cli", "app/yah/cli");
838        reg.save(dir.path()).unwrap();
839
840        let loaded = AppsRegistry::load(dir.path()).unwrap();
841        assert_eq!(loaded.entries.len(), 2);
842        assert_eq!(loaded.entries[0].name, "yah-desktop");
843        assert_eq!(loaded.entries[1].name, "yah-cli");
844    }
845
846    #[test]
847    fn apps_registry_add_updates_existing_entry() {
848        let mut reg = AppsRegistry::default();
849        reg.add("yah-desktop", "old/path");
850        reg.add("yah-desktop", "new/path");
851        assert_eq!(reg.entries.len(), 1);
852        assert_eq!(reg.entries[0].path, PathBuf::from("new/path"));
853    }
854
855    #[test]
856    fn apps_registry_remove_works() {
857        let mut reg = AppsRegistry::default();
858        reg.add("a", "a/");
859        reg.add("b", "b/");
860        assert!(reg.remove("a"));
861        assert!(!reg.remove("a")); // idempotent
862        assert_eq!(reg.entries.len(), 1);
863        assert_eq!(reg.entries[0].name, "b");
864    }
865
866    // ── Discovery ─────────────────────────────────────────────────────────
867
868    #[test]
869    fn find_yah_app_tomls_discovers_at_multiple_depths() {
870        let dir = tempdir().unwrap();
871        let app1 = dir.path().join("app/desktop");
872        let app2 = dir.path().join("app/cli");
873        std::fs::create_dir_all(&app1).unwrap();
874        std::fs::create_dir_all(&app2).unwrap();
875        std::fs::write(app1.join("yah-app.toml"), "").unwrap();
876        std::fs::write(app2.join("yah-app.toml"), "").unwrap();
877
878        let found = find_yah_app_tomls(dir.path(), 4).unwrap();
879        assert_eq!(found.len(), 2);
880        assert!(found.contains(&app1));
881        assert!(found.contains(&app2));
882    }
883
884    #[test]
885    fn find_yah_app_tomls_respects_max_depth() {
886        let dir = tempdir().unwrap();
887        // depth 3 — should be found
888        let shallow = dir.path().join("a/b/c");
889        // depth 5 — should be skipped with maxdepth=4
890        let deep = dir.path().join("a/b/c/d/e");
891        std::fs::create_dir_all(&shallow).unwrap();
892        std::fs::create_dir_all(&deep).unwrap();
893        std::fs::write(shallow.join("yah-app.toml"), "").unwrap();
894        std::fs::write(deep.join("yah-app.toml"), "").unwrap();
895
896        let found = find_yah_app_tomls(dir.path(), 4).unwrap();
897        assert!(found.contains(&shallow), "shallow should be found");
898        assert!(!found.contains(&deep), "deep should be skipped");
899    }
900
901    #[test]
902    fn discover_app_manifests_uses_registry_when_present() {
903        let dir = tempdir().unwrap();
904        std::fs::create_dir_all(dir.path().join(".yah")).unwrap();
905
906        let app_dir = dir.path().join("myapp");
907        std::fs::create_dir_all(&app_dir).unwrap();
908        let manifest = AppManifest {
909            schema_version: 1,
910            name: "myapp".into(),
911            asset_deps: vec![],
912        };
913        manifest.save(&app_dir).unwrap();
914
915        let mut reg = AppsRegistry::default();
916        reg.add("myapp", "myapp");
917        reg.save(dir.path()).unwrap();
918
919        let found = discover_app_manifests(dir.path()).unwrap();
920        assert_eq!(found.len(), 1);
921        assert_eq!(found[0].1.name, "myapp");
922    }
923
924    #[test]
925    fn discover_app_manifests_falls_back_to_find_when_no_registry() {
926        let dir = tempdir().unwrap();
927        std::fs::create_dir_all(dir.path().join(".yah")).unwrap();
928
929        let app_dir = dir.path().join("apps/desktop");
930        std::fs::create_dir_all(&app_dir).unwrap();
931        let manifest = AppManifest {
932            schema_version: 1,
933            name: "desktop".into(),
934            asset_deps: vec![],
935        };
936        manifest.save(&app_dir).unwrap();
937
938        let found = discover_app_manifests(dir.path()).unwrap();
939        assert_eq!(found.len(), 1);
940        assert_eq!(found[0].1.name, "desktop");
941    }
942}