1use crate::config::{AppRuntime, ServeConfig};
2
3#[derive(Debug, Clone, Default, Eq, PartialEq)]
5pub struct RuntimeStack {
6 pub apps: Vec<AppRuntime>,
7 pub failed_apps: Vec<RuntimeAppLoadFailure>,
8 pub host: Option<String>,
9 pub port: Option<String>,
10 pub socket_path: Option<String>,
11}
12
13#[derive(Debug, Clone, Default, Eq, PartialEq)]
15pub struct RuntimeAppLoadFailure {
16 pub app_id: String,
17 pub mount_path: String,
18 pub app_root: String,
19 pub artifact_dir: String,
20 pub error: String,
21}
22
23#[derive(Debug, Clone, Default, Eq, PartialEq)]
25pub struct RuntimeAppLoadOutcome {
26 pub index: usize,
27 pub app: Option<AppRuntime>,
28 pub failure: Option<RuntimeAppLoadFailure>,
29}
30
31#[derive(Debug, Clone, Eq, PartialEq)]
33pub struct RuntimeStackLoadConfig {
34 pub apps_dir_path: std::path::PathBuf,
35 pub host: Option<String>,
36 pub port: Option<String>,
37 pub socket_path: Option<String>,
38 pub effective_config: ServeConfig,
39}
40
41pub fn path_last_segment(path: &std::path::Path) -> Option<String> {
43 path.file_name()
44 .and_then(|segment| segment.to_str())
45 .map(ToOwned::to_owned)
46}
47
48pub fn app_directory_matches_id(app_root: &std::path::Path, app_id: &str) -> bool {
50 !app_id.is_empty() && path_last_segment(app_root).as_deref() == Some(app_id)
51}
52
53pub fn apps_dir_has_root_manifest(apps_dir: &std::path::Path) -> bool {
55 apps_dir.join("pharos.app.json").exists() || apps_dir.join("pharos.app.yml").exists()
56}
57
58pub fn app_load_parallelism(app_count: usize, available_parallelism: usize) -> usize {
60 available_parallelism.clamp(1, 8).min(app_count.max(1))
61}
62
63pub fn app_load_failure(
65 app_root: &std::path::Path,
66 app_id: Option<String>,
67 mount_path: Option<String>,
68 artifact_dir: Option<&std::path::Path>,
69 error: &str,
70) -> RuntimeAppLoadFailure {
71 RuntimeAppLoadFailure {
72 app_id: app_id.unwrap_or_default(),
73 mount_path: mount_path.unwrap_or_default(),
74 app_root: app_root.to_string_lossy().into_owned(),
75 artifact_dir: artifact_dir
76 .map(|path| path.to_string_lossy().into_owned())
77 .unwrap_or_default(),
78 error: error.to_owned(),
79 }
80}
81
82pub fn runtime_apps_dir(
84 conf_path: Option<&std::path::Path>,
85 current_dir: &std::path::Path,
86) -> Option<String> {
87 let Some(conf_path) = conf_path else {
88 return Some(current_dir.to_string_lossy().into_owned());
89 };
90
91 let raw = crate::config::dynamic_config_lookup_raw(conf_path, "apps_dir");
92 if raw.as_deref().is_none_or(str::is_empty) {
93 return Some(current_dir.to_string_lossy().into_owned());
94 }
95 crate::config::resolve_conf_relative_path(conf_path, raw.as_deref().unwrap_or_default())
96}
97
98pub fn resolve_path_relative_to(base_dir: &std::path::Path, value: &str) -> Option<String> {
100 if value.is_empty() {
101 return None;
102 }
103 let path = std::path::Path::new(value);
104 if path.is_absolute() {
105 return Some(value.to_owned());
106 }
107 Some(base_dir.join(path).to_string_lossy().into_owned())
108}
109
110fn resolve_value_or_default(value: Option<&str>, fallback: Option<String>) -> Option<String> {
111 value
112 .filter(|value| !value.is_empty())
113 .map(ToOwned::to_owned)
114 .or(fallback)
115}
116
117pub fn resolve_runtime_stack_load_config(
119 config_record: &ServeConfig,
120 current_dir: &std::path::Path,
121) -> Result<RuntimeStackLoadConfig, &'static str> {
122 let runtime_conf_path = config_record
123 .conf_path
124 .as_deref()
125 .filter(|value| !value.is_empty());
126 let runtime_conf_path = runtime_conf_path.map(|conf_path| {
127 resolve_path_relative_to(current_dir, conf_path).unwrap_or_else(|| conf_path.to_owned())
128 });
129 let runtime_conf_dir = runtime_conf_path.as_deref().and_then(|path| {
130 std::path::Path::new(path)
131 .parent()
132 .map(std::path::Path::to_path_buf)
133 });
134 let runtime_conf_path_ref = runtime_conf_path.as_deref().map(std::path::Path::new);
135
136 let apps_dir =
137 runtime_apps_dir(runtime_conf_path_ref, current_dir).ok_or("missing apps_dir")?;
138 let apps_dir_path = std::path::PathBuf::from(&apps_dir);
139 if !apps_dir_path.is_dir() {
140 return Err("apps_dir missing");
141 }
142
143 let host = resolve_value_or_default(
144 config_record.host.as_deref(),
145 runtime_conf_path_ref
146 .and_then(|path| crate::config::dynamic_config_lookup_raw(path, "host")),
147 )
148 .or_else(|| Some("127.0.0.1".to_owned()));
149 let port = resolve_value_or_default(
150 config_record.port.as_deref(),
151 runtime_conf_path_ref
152 .and_then(|path| crate::config::dynamic_config_lookup_raw(path, "port")),
153 )
154 .or_else(|| Some("7323".to_owned()));
155 let socket_path = if config_record
156 .socket_path
157 .as_deref()
158 .is_some_and(|value| !value.is_empty())
159 {
160 config_record.socket_path.clone()
161 } else if let (Some(runtime_conf_path_ref), Some(runtime_conf_dir)) =
162 (runtime_conf_path_ref, runtime_conf_dir.as_ref())
163 {
164 crate::config::dynamic_config_lookup_raw(runtime_conf_path_ref, "socket_path")
165 .and_then(|value| resolve_path_relative_to(runtime_conf_dir, &value))
166 } else {
167 None
168 };
169
170 let mut effective_config = config_record.clone();
171 if effective_config
172 .build_dir
173 .as_deref()
174 .is_none_or(str::is_empty)
175 && let (Some(runtime_conf_path_ref), Some(runtime_conf_dir)) =
176 (runtime_conf_path_ref, runtime_conf_dir.as_ref())
177 && let Some(raw) =
178 crate::config::dynamic_config_lookup_raw(runtime_conf_path_ref, "build_dir")
179 {
180 effective_config.build_dir = resolve_path_relative_to(runtime_conf_dir, &raw);
181 effective_config.build_dir_from_conf = effective_config.build_dir.is_some();
182 }
183 if effective_config
184 .state_dir
185 .as_deref()
186 .is_none_or(str::is_empty)
187 && let (Some(runtime_conf_path_ref), Some(runtime_conf_dir)) =
188 (runtime_conf_path_ref, runtime_conf_dir.as_ref())
189 && let Some(raw) =
190 crate::config::dynamic_config_lookup_raw(runtime_conf_path_ref, "state_dir")
191 {
192 effective_config.state_dir = resolve_path_relative_to(runtime_conf_dir, &raw);
193 effective_config.state_dir_from_conf = effective_config.state_dir.is_some();
194 }
195 if effective_config
196 .state_path
197 .as_deref()
198 .is_none_or(str::is_empty)
199 && let (Some(runtime_conf_path_ref), Some(runtime_conf_dir)) =
200 (runtime_conf_path_ref, runtime_conf_dir.as_ref())
201 && let Some(raw) =
202 crate::config::dynamic_config_lookup_raw(runtime_conf_path_ref, "state_path")
203 {
204 effective_config.state_path = resolve_path_relative_to(runtime_conf_dir, &raw);
205 effective_config.state_path_from_conf = effective_config.state_path.is_some();
206 }
207 if effective_config
208 .base_path
209 .as_deref()
210 .is_none_or(str::is_empty)
211 && let Some(runtime_conf_path_ref) = runtime_conf_path_ref
212 {
213 effective_config.base_path =
214 crate::config::dynamic_config_lookup_raw(runtime_conf_path_ref, "base_path");
215 effective_config.base_path_from_conf = effective_config
216 .base_path
217 .as_deref()
218 .is_some_and(|value| !value.is_empty());
219 }
220
221 Ok(RuntimeStackLoadConfig {
222 apps_dir_path,
223 host,
224 port,
225 socket_path,
226 effective_config,
227 })
228}
229
230fn json_string(value: &serde_json::Value, key: &str) -> Option<String> {
231 value
232 .get(key)
233 .and_then(serde_json::Value::as_str)
234 .map(ToOwned::to_owned)
235}
236
237fn json_runtime_string(value: &serde_json::Value, key: &str) -> Option<String> {
238 value
239 .get("runtime")
240 .and_then(serde_json::Value::as_object)
241 .and_then(|runtime| runtime.get(key))
242 .and_then(serde_json::Value::as_str)
243 .map(ToOwned::to_owned)
244}
245
246#[derive(Debug, Clone, Eq, PartialEq)]
248pub struct RuntimeAppStartupTiming {
249 pub app_id: String,
250 pub artifact_dir: std::path::PathBuf,
251 pub load_ms: u128,
252 pub finalize_ms: u128,
253 pub state_ms: u128,
254 pub verify_ms: u128,
255 pub total_ms: u128,
256}
257
258pub trait RuntimeAppLoadService: Clone + Send + Sync + 'static {
260 fn read_source_manifest_value(
261 &self,
262 app_root: &std::path::Path,
263 ) -> Result<serde_json::Value, &'static str>;
264
265 fn packaged_artifact_root_matches(&self, app_root: &std::path::Path, target_id: &str) -> bool;
266
267 fn app_runtime_conf_required_present(&self, app_root: &std::path::Path) -> bool;
268
269 fn resolve_build_dir_for_runtime_loading(
270 &self,
271 app_root: &std::path::Path,
272 build_dir: &str,
273 repo_root: Option<&std::path::Path>,
274 ) -> std::path::PathBuf;
275
276 fn load_app_runtime_from_artifact(
277 &self,
278 artifact_dir: &std::path::Path,
279 ) -> Result<AppRuntime, &'static str>;
280
281 fn initialize_missing_app_runtime_state(&self, app: &AppRuntime) -> Result<(), &'static str>;
282
283 fn verify_app_migration_state(&self, app: &AppRuntime) -> Result<(), &'static str>;
284
285 fn log_startup_app_timing(&self, _timing: RuntimeAppStartupTiming) {}
286}
287
288pub fn load_packaged_artifact_with_service<S: RuntimeAppLoadService>(
290 service: &S,
291 config_record: &ServeConfig,
292 artifact_root: &std::path::Path,
293) -> Result<(AppRuntime, String), &'static str> {
294 let direct_root = artifact_root.to_path_buf();
295 let direct_build_dir =
296 crate::config::effective_app_build_dir(&direct_root, config_record.build_dir.as_deref())
297 .ok_or("missing build_dir")?;
298 let mut app = service.load_app_runtime_from_artifact(&direct_root)?;
299 crate::config::finalize_loaded_app(
300 &mut app,
301 Some(config_record),
302 Some(&direct_root),
303 Some(std::path::Path::new(&direct_build_dir)),
304 )
305 .map_err(|_| "finalize failed")?;
306 service.initialize_missing_app_runtime_state(&app)?;
307 Ok((app, direct_root.to_string_lossy().into_owned()))
308}
309
310fn load_direct_source_or_artifact_with_service<S: RuntimeAppLoadService>(
311 service: &S,
312 config_record: &ServeConfig,
313 app_root: &std::path::Path,
314 repo_root: Option<&std::path::Path>,
315) -> Result<AppRuntime, &'static str> {
316 let target_id = config_record
317 .target_id
318 .as_deref()
319 .ok_or("missing target_id")?;
320 if !service.app_runtime_conf_required_present(app_root) {
321 return Err("missing app.conf");
322 }
323
324 let build_dir =
325 crate::config::effective_app_build_dir(app_root, config_record.build_dir.as_deref())
326 .ok_or("missing build_dir")?;
327 let resolved_build_dir =
328 service.resolve_build_dir_for_runtime_loading(app_root, &build_dir, repo_root);
329 if service.packaged_artifact_root_matches(app_root, target_id) {
330 return load_packaged_artifact_with_service(service, config_record, app_root)
331 .map(|(app, _)| app);
332 }
333
334 let artifact_dir =
335 crate::config::effective_app_artifact_dir(app_root, &resolved_build_dir, target_id)
336 .ok_or("missing artifact_dir")?;
337 if !std::path::Path::new(&artifact_dir).is_dir() {
338 return Err("artifact_dir missing");
339 }
340
341 let mut app = service.load_app_runtime_from_artifact(std::path::Path::new(&artifact_dir))?;
342 crate::config::finalize_loaded_app(
343 &mut app,
344 Some(config_record),
345 Some(app_root),
346 Some(&resolved_build_dir),
347 )
348 .map_err(|_| "finalize failed")?;
349 service.initialize_missing_app_runtime_state(&app)?;
350 Ok(app)
351}
352
353pub fn load_app_runtime_for_single_app_direct_with_service<S: RuntimeAppLoadService>(
355 service: &S,
356 config_record: &ServeConfig,
357 repo_root: Option<&std::path::Path>,
358 binary_dir: Option<&std::path::Path>,
359) -> Result<AppRuntime, &'static str> {
360 let target_id = config_record
361 .target_id
362 .as_deref()
363 .filter(|value| !value.is_empty())
364 .ok_or("missing target_id")?;
365
366 if let Some(app_dir) = config_record.app_dir.as_deref().map(std::path::Path::new) {
367 if service.packaged_artifact_root_matches(app_dir, target_id) {
368 return load_packaged_artifact_with_service(service, config_record, app_dir)
369 .map(|(app, _)| app);
370 }
371 if let Some(manifest) = crate::config::match_app_root_in_directory(app_dir, target_id) {
372 let app_root = std::path::PathBuf::from(manifest);
373 return load_direct_source_or_artifact_with_service(
374 service,
375 config_record,
376 &app_root,
377 repo_root,
378 );
379 }
380 if let Ok(manifest) = service.read_source_manifest_value(app_dir)
381 && json_string(&manifest, "app_id").as_deref() == Some(target_id)
382 {
383 return load_direct_source_or_artifact_with_service(
384 service,
385 config_record,
386 app_dir,
387 repo_root,
388 );
389 }
390 }
391
392 if let Some(app_root) = crate::config::find_app_root_for_target(
393 target_id,
394 config_record.app_dir.as_deref().map(std::path::Path::new),
395 repo_root,
396 binary_dir,
397 ) {
398 return load_direct_source_or_artifact_with_service(
399 service,
400 config_record,
401 std::path::Path::new(&app_root),
402 repo_root,
403 );
404 }
405
406 Err("app target could not be resolved to a packaged artifact")
407}
408
409pub fn load_runtime_stack_app_entry_with_service<S: RuntimeAppLoadService>(
411 service: &S,
412 index: usize,
413 app_root: std::path::PathBuf,
414 effective_config: ServeConfig,
415) -> RuntimeAppLoadOutcome {
416 let failure = |app_id: Option<String>,
417 mount_path: Option<String>,
418 artifact_dir: Option<&std::path::Path>,
419 error: &'static str| {
420 RuntimeAppLoadOutcome {
421 index,
422 app: None,
423 failure: Some(app_load_failure(
424 &app_root,
425 app_id.or_else(|| path_last_segment(&app_root)),
426 mount_path,
427 artifact_dir,
428 error,
429 )),
430 }
431 };
432
433 let manifest = match service.read_source_manifest_value(&app_root) {
434 Ok(manifest) => manifest,
435 Err(_) => return failure(None, None, None, "runtime app manifest unreadable"),
436 };
437 let app_id = match json_string(&manifest, "app_id") {
438 Some(app_id) => app_id,
439 None => {
440 return failure(
441 None,
442 json_runtime_string(&manifest, "base_path"),
443 None,
444 "runtime app manifest missing app_id",
445 );
446 }
447 };
448 let mount_path = json_runtime_string(&manifest, "base_path");
449 if !app_directory_matches_id(&app_root, &app_id) {
450 return failure(
451 Some(app_id),
452 mount_path,
453 None,
454 "runtime app directory does not match app_id",
455 );
456 }
457 if !service.app_runtime_conf_required_present(&app_root) {
458 return failure(
459 Some(app_id),
460 mount_path,
461 None,
462 "runtime app artifact configuration missing",
463 );
464 }
465 let artifact_dir = if service.packaged_artifact_root_matches(&app_root, &app_id) {
466 app_root.clone()
467 } else {
468 let Some(build_dir) = crate::config::effective_app_build_dir(
469 &app_root,
470 effective_config.build_dir.as_deref(),
471 ) else {
472 return failure(Some(app_id), mount_path, None, "missing build_dir");
473 };
474 let resolved_build_dir =
475 service.resolve_build_dir_for_runtime_loading(&app_root, &build_dir, None);
476 let Some(artifact_dir) =
477 crate::config::effective_app_artifact_dir(&app_root, &resolved_build_dir, &app_id)
478 else {
479 return failure(
480 Some(app_id),
481 mount_path,
482 Some(&resolved_build_dir),
483 "missing artifact_dir",
484 );
485 };
486 std::path::PathBuf::from(artifact_dir)
487 };
488 if !artifact_dir.join("pharos.app.json").is_file() {
489 return failure(
490 Some(app_id),
491 mount_path,
492 Some(&artifact_dir),
493 "runtime app artifact manifest missing",
494 );
495 }
496
497 let app_timing_start = std::time::Instant::now();
498 let load_timing_start = std::time::Instant::now();
499 let mut app = match service.load_app_runtime_from_artifact(&artifact_dir) {
500 Ok(app) => app,
501 Err(error) => return failure(Some(app_id), mount_path, Some(&artifact_dir), error),
502 };
503 let load_ms = load_timing_start.elapsed().as_millis();
504 let Some(build_dir) =
505 crate::config::effective_app_build_dir(&app_root, effective_config.build_dir.as_deref())
506 else {
507 return failure(
508 app.app_id.clone(),
509 app.base_path.clone().or(mount_path),
510 Some(&artifact_dir),
511 "missing build_dir",
512 );
513 };
514 let resolved_build_dir =
515 service.resolve_build_dir_for_runtime_loading(&app_root, &build_dir, None);
516 let finalize_timing_start = std::time::Instant::now();
517 if crate::config::finalize_loaded_app(
518 &mut app,
519 Some(&effective_config),
520 Some(&app_root),
521 Some(&resolved_build_dir),
522 )
523 .is_err()
524 {
525 return failure(
526 app.app_id.clone(),
527 app.base_path.clone().or(mount_path),
528 Some(&artifact_dir),
529 "finalize failed",
530 );
531 }
532 let finalize_ms = finalize_timing_start.elapsed().as_millis();
533 let state_timing_start = std::time::Instant::now();
534 if let Err(error) = service.initialize_missing_app_runtime_state(&app) {
535 return failure(
536 app.app_id.clone(),
537 app.base_path.clone().or(mount_path),
538 Some(&artifact_dir),
539 error,
540 );
541 }
542 let state_ms = state_timing_start.elapsed().as_millis();
543 let verify_timing_start = std::time::Instant::now();
544 if let Err(error) = service.verify_app_migration_state(&app) {
545 return failure(
546 app.app_id.clone(),
547 app.base_path.clone().or(mount_path),
548 Some(&artifact_dir),
549 error,
550 );
551 }
552 let verify_ms = verify_timing_start.elapsed().as_millis();
553 service.log_startup_app_timing(RuntimeAppStartupTiming {
554 app_id: app.app_id.as_deref().unwrap_or(&app_id).to_owned(),
555 artifact_dir: artifact_dir.clone(),
556 load_ms,
557 finalize_ms,
558 state_ms,
559 verify_ms,
560 total_ms: app_timing_start.elapsed().as_millis(),
561 });
562 RuntimeAppLoadOutcome {
563 index,
564 app: Some(app),
565 failure: None,
566 }
567}
568
569pub fn load_runtime_stack_from_apps_dir_with_service<S: RuntimeAppLoadService>(
571 apps_dir_path: &std::path::Path,
572 host: Option<String>,
573 port: Option<String>,
574 socket_path: Option<String>,
575 effective_config: ServeConfig,
576 service: S,
577 validate_ownership: impl FnOnce(&[AppRuntime]) -> Result<(), &'static str>,
578) -> Result<RuntimeStack, &'static str> {
579 load_runtime_stack_from_apps_dir_with_loader(
580 apps_dir_path,
581 host,
582 port,
583 socket_path,
584 effective_config,
585 move |index, app_root, effective_config| {
586 load_runtime_stack_app_entry_with_service(&service, index, app_root, effective_config)
587 },
588 validate_ownership,
589 )
590}
591
592pub fn load_runtime_stack_from_apps_dir(
594 apps_dir_path: &std::path::Path,
595 host: Option<String>,
596 port: Option<String>,
597 socket_path: Option<String>,
598 effective_config: ServeConfig,
599 load_entry: fn(usize, std::path::PathBuf, ServeConfig) -> RuntimeAppLoadOutcome,
600 validate_ownership: impl FnOnce(&[AppRuntime]) -> Result<(), &'static str>,
601) -> Result<RuntimeStack, &'static str> {
602 load_runtime_stack_from_apps_dir_with_loader(
603 apps_dir_path,
604 host,
605 port,
606 socket_path,
607 effective_config,
608 load_entry,
609 validate_ownership,
610 )
611}
612
613fn load_runtime_stack_from_apps_dir_with_loader<F>(
614 apps_dir_path: &std::path::Path,
615 host: Option<String>,
616 port: Option<String>,
617 socket_path: Option<String>,
618 effective_config: ServeConfig,
619 load_entry: F,
620 validate_ownership: impl FnOnce(&[AppRuntime]) -> Result<(), &'static str>,
621) -> Result<RuntimeStack, &'static str>
622where
623 F: Fn(usize, std::path::PathBuf, ServeConfig) -> RuntimeAppLoadOutcome
624 + Clone
625 + Send
626 + Sync
627 + 'static,
628{
629 if !apps_dir_path.is_dir() {
630 return Err("apps_dir missing");
631 }
632 if apps_dir_has_root_manifest(apps_dir_path) {
633 return Err("apps_dir contains a root-level app manifest");
634 }
635
636 let mut app_roots: Vec<std::path::PathBuf> = std::fs::read_dir(apps_dir_path)
637 .map_err(|_| "apps_dir unreadable")?
638 .flatten()
639 .map(|entry| entry.path())
640 .filter(|path| path.is_dir())
641 .collect();
642 app_roots.sort_by(|left, right| {
643 path_last_segment(left)
644 .unwrap_or_default()
645 .cmp(&path_last_segment(right).unwrap_or_default())
646 });
647
648 let parallelism = app_load_parallelism(
649 app_roots.len(),
650 std::thread::available_parallelism()
651 .map(usize::from)
652 .unwrap_or(1),
653 );
654 let mut outcomes = Vec::with_capacity(app_roots.len());
655 for chunk in app_roots.chunks(parallelism) {
656 let mut handles = Vec::with_capacity(chunk.len());
657 for app_root in chunk {
658 let index = outcomes.len() + handles.len();
659 let app_root = app_root.clone();
660 let effective_config = effective_config.clone();
661 let load_entry = load_entry.clone();
662 handles.push(std::thread::spawn(move || {
663 load_entry(index, app_root, effective_config)
664 }));
665 }
666 for handle in handles {
667 match handle.join() {
668 Ok(outcome) => outcomes.push(outcome),
669 Err(_) => outcomes.push(RuntimeAppLoadOutcome {
670 index: outcomes.len(),
671 app: None,
672 failure: Some(RuntimeAppLoadFailure {
673 app_id: "unknown".to_owned(),
674 app_root: String::new(),
675 mount_path: String::new(),
676 artifact_dir: String::new(),
677 error: "runtime app loader panicked".to_owned(),
678 }),
679 }),
680 }
681 }
682 }
683
684 outcomes.sort_by_key(|outcome| outcome.index);
685 let mut stack = RuntimeStack {
686 apps: Vec::new(),
687 failed_apps: Vec::new(),
688 host,
689 port,
690 socket_path,
691 };
692 for outcome in outcomes {
693 if let Some(app) = outcome.app {
694 stack.apps.push(app);
695 }
696 if let Some(failure) = outcome.failure {
697 stack.failed_apps.push(failure);
698 }
699 }
700
701 if stack.apps.is_empty() {
702 for failure in &stack.failed_apps {
703 eprintln!(
704 "runtime app load failed: app_id={} error={}",
705 failure.app_id, failure.error
706 );
707 }
708 return Err("no runtime apps loaded");
709 }
710 validate_ownership(&stack.apps)?;
711 Ok(stack)
712}
713
714#[cfg(test)]
715mod tests {
716 use super::*;
717
718 #[test]
719 fn runtime_stack_records_failed_apps() {
720 let stack = RuntimeStack {
721 failed_apps: vec![RuntimeAppLoadFailure {
722 app_id: "broken_app".to_owned(),
723 mount_path: "/broken".to_owned(),
724 app_root: "apps/broken".to_owned(),
725 artifact_dir: "dist/apps/broken".to_owned(),
726 error: "missing artifact".to_owned(),
727 }],
728 ..RuntimeStack::default()
729 };
730 assert_eq!(stack.failed_apps[0].app_id, "broken_app");
731 assert_eq!(stack.failed_apps[0].mount_path, "/broken");
732 }
733
734 #[test]
735 fn app_load_parallelism_is_bounded() {
736 assert_eq!(app_load_parallelism(0, 0), 1);
737 assert_eq!(app_load_parallelism(1, 64), 1);
738 assert_eq!(app_load_parallelism(10, 64), 8);
739 assert_eq!(app_load_parallelism(3, 2), 2);
740 }
741
742 #[test]
743 fn app_load_failure_records_paths() {
744 let failure = app_load_failure(
745 std::path::Path::new("apps/demo"),
746 Some("demo".to_owned()),
747 Some("/demo".to_owned()),
748 Some(std::path::Path::new("dist/apps/demo")),
749 "missing artifact",
750 );
751 assert_eq!(failure.app_id, "demo");
752 assert_eq!(failure.mount_path, "/demo");
753 assert_eq!(failure.artifact_dir, "dist/apps/demo");
754 }
755
756 #[test]
757 fn runtime_apps_dir_defaults_to_current_dir() {
758 let current = std::path::Path::new("/tmp/pharos-apps");
759 assert_eq!(
760 runtime_apps_dir(None, current).as_deref(),
761 Some("/tmp/pharos-apps")
762 );
763 }
764
765 fn test_loader(
766 index: usize,
767 app_root: std::path::PathBuf,
768 _config: ServeConfig,
769 ) -> RuntimeAppLoadOutcome {
770 RuntimeAppLoadOutcome {
771 index,
772 app: Some(AppRuntime {
773 app_id: path_last_segment(&app_root),
774 app_root: Some(app_root.to_string_lossy().into_owned()),
775 base_path: Some(format!("/{index}")),
776 ..AppRuntime::default()
777 }),
778 failure: None,
779 }
780 }
781
782 #[test]
783 fn stack_loader_merges_ordered_outcomes() {
784 let root = std::env::temp_dir().join(format!("pharos-stack-loader-{}", std::process::id()));
785 let _ = std::fs::remove_dir_all(&root);
786 std::fs::create_dir_all(root.join("b_app")).expect("b app dir");
787 std::fs::create_dir_all(root.join("a_app")).expect("a app dir");
788 let stack = load_runtime_stack_from_apps_dir(
789 &root,
790 Some("127.0.0.1".to_owned()),
791 Some("7323".to_owned()),
792 None,
793 ServeConfig::default(),
794 test_loader,
795 |_| Ok(()),
796 )
797 .expect("stack load");
798 assert_eq!(stack.apps[0].app_id.as_deref(), Some("a_app"));
799 assert_eq!(stack.apps[1].app_id.as_deref(), Some("b_app"));
800 let _ = std::fs::remove_dir_all(root);
801 }
802}