1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum CmpOp {
38 Eq,
39 Ne,
40}
41
42#[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 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#[derive(Debug, Clone)]
80pub struct HostContext {
81 pub target_os: String,
83 pub target_arch: String,
85 pub features: HashSet<String>,
87 pub env: HashMap<String, String>,
89}
90
91impl HostContext {
92 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#[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 RequiredWhen::parse(v).map_err(|e| de::Error::custom(format!("{e:#}")))
148 }
149 }
150 d.deserialize_str(V)
151 }
152}
153
154fn 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; }
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; 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 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 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 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 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
398pub struct AssetDep {
399 pub alias: String,
401 #[serde(skip_serializing_if = "Option::is_none")]
404 pub required_when: Option<RequiredWhen>,
405 #[serde(skip_serializing_if = "Option::is_none")]
407 pub purpose: Option<String>,
408}
409
410impl AssetDep {
411 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#[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 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 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
454pub struct AppRegistryEntry {
455 pub name: String,
456 pub path: PathBuf,
458}
459
460#[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 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 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 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 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
514pub 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 ®istry.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 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
558pub 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 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#[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 #[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 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())); assert!(!p.evaluate(&linux_ctx())); 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 #[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 #[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 #[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")); assert_eq!(reg.entries.len(), 1);
863 assert_eq!(reg.entries[0].name, "b");
864 }
865
866 #[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 let shallow = dir.path().join("a/b/c");
889 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}