1use std::fs;
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4
5use serde_json::Value;
6pub const MODULE_STATUS: &str = "COMPLETE";
18
19#[derive(Debug, Clone, Eq, PartialEq)]
20pub struct ResourceBudgets {
44 pub max_response_bytes: usize,
45 pub max_query_result_bytes: usize,
46 pub max_report_bytes: usize,
47 pub max_state_section_items: usize,
48 pub max_workflow_effects: usize,
49 pub max_jobs_per_poll: usize,
50 pub integration_connect_timeout_ms: u64,
51 pub integration_operation_timeout_ms: u64,
52 pub integration_max_response_bytes: usize,
53}
54
55impl Default for ResourceBudgets {
56 fn default() -> Self {
58 Self {
59 max_response_bytes: 16 * 1024 * 1024,
60 max_query_result_bytes: 8 * 1024 * 1024,
61 max_report_bytes: 8 * 1024 * 1024,
62 max_state_section_items: 10_000,
63 max_workflow_effects: 1_024,
64 max_jobs_per_poll: 128,
65 integration_connect_timeout_ms: 5_000,
66 integration_operation_timeout_ms: 15_000,
67 integration_max_response_bytes: 8 * 1024,
68 }
69 }
70}
71
72#[derive(Debug, Clone, Default, Eq, PartialEq)]
73pub struct ServeConfig {
155 pub kind: Option<String>,
156 pub target_id: Option<String>,
157 pub conf_path: Option<String>,
158 pub app_dir: Option<String>,
159 pub build_dir: Option<String>,
160 pub build_dir_from_conf: bool,
161 pub clean: bool,
162 pub state_dir: Option<String>,
163 pub state_dir_from_conf: bool,
164 pub state_path: Option<String>,
165 pub state_path_from_conf: bool,
166 pub base_path: Option<String>,
167 pub base_path_from_conf: bool,
168 pub log_path: Option<String>,
169 pub log_path_from_conf: bool,
170 pub log_level: Option<String>,
171 pub log_level_from_conf: bool,
172 pub trusted_proxy_cidrs: Option<String>,
173 pub trusted_proxy_cidrs_from_conf: bool,
174 pub redis_enabled: bool,
175 pub redis_enabled_from_conf: bool,
176 pub redis_host: Option<String>,
177 pub redis_host_from_conf: bool,
178 pub redis_port: Option<String>,
179 pub redis_port_from_conf: bool,
180 pub redis_auth_user: Option<String>,
181 pub redis_auth_user_from_conf: bool,
182 pub redis_auth_token_env: Option<String>,
183 pub redis_auth_token_env_from_conf: bool,
184 pub redis_db_index: Option<String>,
185 pub redis_db_index_from_conf: bool,
186 pub redis_connect_timeout_ms: Option<String>,
187 pub redis_connect_timeout_ms_from_conf: bool,
188 pub redis_read_timeout_ms: Option<String>,
189 pub redis_read_timeout_ms_from_conf: bool,
190 pub redis_cache_prefix: Option<String>,
191 pub redis_cache_prefix_from_conf: bool,
192 pub redis_backoff_seconds: Option<String>,
193 pub redis_backoff_seconds_from_conf: bool,
194 pub redis_backoff_max_seconds: Option<String>,
195 pub redis_backoff_max_seconds_from_conf: bool,
196 pub redis_tls_mode: Option<String>,
197 pub redis_tls_mode_from_conf: bool,
198 pub redis_tls_root_cert: Option<String>,
199 pub redis_tls_root_cert_from_conf: bool,
200 pub redis_tls_cert: Option<String>,
201 pub redis_tls_cert_from_conf: bool,
202 pub redis_tls_key: Option<String>,
203 pub redis_tls_key_from_conf: bool,
204 pub redis_require_peer_name_match: bool,
205 pub redis_require_peer_name_match_from_conf: bool,
206 pub redis_cache_sessions_enabled: bool,
207 pub redis_cache_sessions_enabled_from_conf: bool,
208 pub redis_cache_template_source_enabled: bool,
209 pub redis_cache_template_source_enabled_from_conf: bool,
210 pub redis_cache_route_match_enabled: bool,
211 pub redis_cache_route_match_enabled_from_conf: bool,
212 pub redis_cache_config_allowlist_enabled: bool,
213 pub redis_cache_config_allowlist_enabled_from_conf: bool,
214 pub socket_path: Option<String>,
215 pub socket_path_from_conf: bool,
216 pub host: Option<String>,
217 pub host_from_conf: bool,
218 pub port: Option<String>,
219 pub port_from_conf: bool,
220 pub root: Option<String>,
221 pub seed_state: bool,
222}
223
224#[derive(Debug, Clone, Default, Eq, PartialEq)]
225pub struct AppLocalConfig {
253 pub conf_path: Option<String>,
254 pub build_dir: Option<String>,
255 pub state_dir: Option<String>,
256 pub state_path: Option<String>,
257 pub base_path: Option<String>,
258 pub log_path: Option<String>,
259 pub socket_path: Option<String>,
260 pub error_report_path: Option<String>,
261 pub log_level: Option<String>,
262 pub trusted_proxy_cidrs: Option<String>,
263 pub manifest_build_artifact_dir: Option<String>,
264 pub manifest_state_path: Option<String>,
265 pub manifest_base_path: Option<String>,
266}
267
268#[derive(Debug, Clone, Default, Eq, PartialEq)]
269pub struct AppRuntime {
303 pub connection_namespace: Option<String>,
305 pub application_ir: Option<Arc<crate::application_ir::ApplicationIr>>,
306 pub app_id: Option<String>,
307 pub app_root: Option<String>,
308 pub build_dir: Option<String>,
309 pub artifact_dir: Option<String>,
310 pub host_profile: Option<String>,
311 pub state_path: Option<String>,
312 pub base_path: Option<String>,
313 pub log_path: Option<String>,
314 pub log_format: Option<String>,
315 pub error_report_path: Option<String>,
316 pub log_level: Option<String>,
317 pub trusted_proxy_cidrs: Option<String>,
318 pub runtime_conf_path: Option<String>,
319 pub is_editor_preview: bool,
321 pub queue_enabled: bool,
322 pub queue_poll_interval_ms: i64,
323 pub queue_max_attempts: i64,
324 pub queue_retry_seconds: i64,
325 pub resource_budgets: ResourceBudgets,
326}
327pub fn clone_app(app: &AppRuntime) -> AppRuntime {
347 app.clone()
348}
349
350fn read_file_text(path: &Path) -> Option<String> {
352 fs::read_to_string(path).ok()
353}
354
355fn path_exists(path: &Path) -> bool {
357 path.exists()
358}
359
360fn resolve_relative_to(base_dir: &Path, value: &str) -> Option<String> {
362 if value.is_empty() {
363 return None;
364 }
365
366 let path = Path::new(value);
367 if path.is_absolute() {
368 return Some(value.to_owned());
369 }
370
371 Some(base_dir.join(path).to_string_lossy().into_owned())
372}
373
374fn current_working_directory() -> String {
376 std::env::current_dir()
377 .unwrap_or_else(|_| PathBuf::from("."))
378 .to_string_lossy()
379 .into_owned()
380}
381
382fn path_dirname(path: &Path) -> Option<PathBuf> {
384 if path.as_os_str().is_empty() {
385 return Some(PathBuf::from("."));
386 }
387
388 match path.parent() {
389 Some(parent) if parent.as_os_str().is_empty() => Some(PathBuf::from(".")),
390 Some(parent) => Some(parent.to_path_buf()),
391 None => Some(PathBuf::from(".")),
392 }
393}
394
395fn string_from_json_value(value: &Value) -> Option<String> {
397 value.as_str().map(ToOwned::to_owned)
398}
399
400fn parse_json_text(text: &str) -> Option<Value> {
402 serde_json::from_str(text).ok()
403}
404
405fn parse_yaml_text(text: &str) -> Option<Value> {
407 crate::yaml::parse_yaml_text(text)
408}
409
410fn load_manifest_value(app_root: &Path) -> Option<Value> {
412 let yaml_path = app_root.join("pharos.app.yml");
413 if path_exists(&yaml_path) {
414 let yaml_text = read_file_text(&yaml_path)?;
415 let value = parse_yaml_text(&yaml_text)?;
416 if manifest_has_required_fields(&value) {
417 return Some(value);
418 }
419 return None;
420 }
421
422 let json_path = app_root.join("pharos.app.json");
423 if path_exists(&json_path) {
424 let json_text = read_file_text(&json_path)?;
425 let value = parse_json_text(&json_text)?;
426 if manifest_has_required_fields(&value) {
427 return Some(value);
428 }
429 }
430
431 None
432}
433
434fn manifest_has_required_fields(value: &Value) -> bool {
436 let app_id_ok = value
437 .get("app_id")
438 .and_then(Value::as_str)
439 .is_some_and(|text| !text.is_empty());
440 let host_profile_ok = value
441 .get("runtime")
442 .and_then(Value::as_object)
443 .and_then(|runtime| runtime.get("host_profile"))
444 .and_then(Value::as_str)
445 .is_some_and(|text| !text.is_empty());
446
447 app_id_ok && host_profile_ok
448}
449
450fn manifest_app_id(value: &Value) -> Option<&str> {
452 value
453 .get("app_id")
454 .and_then(Value::as_str)
455 .filter(|text| !text.is_empty())
456}
457
458fn canonicalize_or_original(path: &Path) -> String {
460 path.to_string_lossy().into_owned()
461}
462
463fn has_override_value(value: Option<&str>) -> bool {
465 value.is_some_and(|value| !value.is_empty())
466}
467
468fn has_cli_override_value(value: Option<&str>, from_conf: bool) -> bool {
470 has_override_value(value) && !from_conf
471}
472
473fn has_runtime_default_value(value: Option<&str>, from_conf: bool) -> bool {
475 has_override_value(value) && from_conf
476}
477
478fn parse_queue_enabled(value: Option<&str>) -> bool {
480 matches!(value, Some("1" | "true"))
481}
482
483fn parse_config_bool(value: Option<&str>, default_value: bool) -> bool {
485 value
486 .map(|value| parse_queue_enabled(Some(value)))
487 .unwrap_or(default_value)
488}
489
490fn parse_positive_i64(value: Option<&str>, default_value: i64) -> i64 {
492 value
493 .and_then(|value| value.parse::<i64>().ok())
494 .filter(|value| *value > 0)
495 .unwrap_or(default_value)
496}
497pub fn match_app_root_in_directory(base_dir: &Path, target_id: &str) -> Option<String> {
522 if base_dir.as_os_str().is_empty() || target_id.is_empty() {
523 return None;
524 }
525
526 if let Some(manifest) = load_manifest_value(base_dir)
527 && manifest_app_id(&manifest) == Some(target_id)
528 {
529 return Some(canonicalize_or_original(base_dir));
530 }
531
532 let directory = fs::read_dir(base_dir).ok()?;
533 for entry in directory.flatten() {
534 let child_root = entry.path();
535 if !child_root.is_dir() {
536 continue;
537 }
538 if let Some(manifest) = load_manifest_value(&child_root)
539 && manifest_app_id(&manifest) == Some(target_id)
540 {
541 return Some(canonicalize_or_original(&child_root));
542 }
543 }
544
545 None
546}
547
548pub fn find_app_root_for_target(
571 target_id: &str,
572 app_dir_hint: Option<&Path>,
573 repo_root: Option<&Path>,
574 binary_dir: Option<&Path>,
575) -> Option<String> {
576 if let Some(app_dir_hint) = app_dir_hint.filter(|path| !path.as_os_str().is_empty())
577 && let Some(matched) = match_app_root_in_directory(app_dir_hint, target_id)
578 {
579 return Some(matched);
580 }
581
582 if let Some(repo_root) = repo_root.filter(|path| !path.as_os_str().is_empty())
583 && let Some(matched) = match_app_root_in_directory(&repo_root.join("apps"), target_id)
584 {
585 return Some(matched);
586 }
587
588 if let Some(binary_dir) = binary_dir.filter(|path| !path.as_os_str().is_empty()) {
589 if let Some(matched) = match_app_root_in_directory(&binary_dir.join("apps"), target_id) {
590 return Some(matched);
591 }
592 if let Some(binary_parent) = path_dirname(binary_dir)
593 && let Some(matched) =
594 match_app_root_in_directory(&binary_parent.join("apps"), target_id)
595 {
596 return Some(matched);
597 }
598 }
599
600 None
601}
602pub fn resolve_conf_relative_path(conf_path: &Path, raw_value: &str) -> Option<String> {
623 if raw_value.is_empty() {
624 return Some(String::new());
625 }
626
627 let candidate = Path::new(raw_value);
628 if candidate.is_absolute() {
629 return Some(raw_value.to_owned());
630 }
631
632 let conf_dir = path_dirname(conf_path)?;
633 Some(conf_dir.join(candidate).to_string_lossy().into_owned())
634}
635pub fn app_local_conf_path(app_root: &Path) -> Option<PathBuf> {
655 let path = if app_root.as_os_str().is_empty() {
656 PathBuf::from("app.conf")
657 } else {
658 app_root.join("app.conf")
659 };
660
661 path_exists(&path).then_some(path)
662}
663
664fn resolve_conf_value_key(conf_path: &Path, key: &str, value: Option<&str>) -> Option<String> {
666 let value = value?;
667
668 match key {
669 "app_dir" | "build_dir" | "state_dir" | "state_path" | "log_path" | "socket_path"
670 | "error_report_path" => resolve_conf_relative_path(conf_path, value),
671 _ => Some(value.to_owned()),
672 }
673}
674
675fn conf_lookup_value_in_text(text: &str, key: &str) -> Option<String> {
677 text.lines().find_map(|line| {
678 let trimmed = line.trim();
679 if trimmed.is_empty() || trimmed.starts_with('#') {
680 return None;
681 }
682
683 let (raw_key, raw_value) = trimmed.split_once('=')?;
684 let parsed_key = raw_key.trim();
685 if parsed_key != key {
686 return None;
687 }
688
689 Some(raw_value.trim().to_owned())
690 })
691}
692
693pub fn dynamic_config_value_is_set(value: Option<&str>) -> bool {
713 matches!(value, Some(value) if !value.is_empty() && value != "null")
714}
715
716pub fn dynamic_config_key_is_path(key: &str) -> bool {
736 matches!(
737 key,
738 "db_path" | "db_tls_root_cert" | "db_tls_cert" | "db_tls_key" | "backup_dir" | "MEDIA_ROOT"
739 )
740}
741
742pub fn dynamic_config_resolve_value(
764 conf_path: &Path,
765 key: &str,
766 raw_value: &str,
767) -> Option<String> {
768 if dynamic_config_key_is_path(key) {
769 resolve_conf_relative_path(conf_path, raw_value)
770 } else {
771 Some(raw_value.to_owned())
772 }
773}
774
775pub fn dynamic_config_lookup_raw(conf_path: &Path, key: &str) -> Option<String> {
796 if key.is_empty() || !path_exists(conf_path) {
797 return None;
798 }
799
800 let text = read_file_text(conf_path)?;
801 conf_lookup_value_in_text(&text, key)
802}
803
804pub fn dynamic_config_effective_value(
826 app_root: &Path,
827 runtime_conf_path: Option<&Path>,
828 key: &str,
829) -> Option<String> {
830 if key.is_empty() {
831 return None;
832 }
833
834 if let Some(app_conf_path) = app_local_conf_path(app_root) {
835 let raw_value = dynamic_config_lookup_raw(&app_conf_path, key);
836 if dynamic_config_value_is_set(raw_value.as_deref()) {
837 return dynamic_config_resolve_value(&app_conf_path, key, raw_value.as_deref()?);
838 }
839 }
840
841 if let Some(runtime_conf_path) = runtime_conf_path {
842 let raw_value = dynamic_config_lookup_raw(runtime_conf_path, key);
843 if dynamic_config_value_is_set(raw_value.as_deref()) {
844 return dynamic_config_resolve_value(runtime_conf_path, key, raw_value.as_deref()?);
845 }
846 }
847
848 None
849}
850
851pub fn app_media_root(app_root: &Path) -> Option<PathBuf> {
854 let configured = dynamic_config_effective_value(app_root, None, "MEDIA_ROOT");
855 match configured {
856 Some(value) => {
857 let path = PathBuf::from(value);
858 (path.is_absolute()
859 && !path
860 .components()
861 .any(|part| matches!(part, std::path::Component::ParentDir)))
862 .then_some(path)
863 }
864 None => Some(app_root.join("media")),
865 }
866}
867
868pub fn duplicate_config_field(config: &AppLocalConfig, key: &str) -> Option<String> {
889 match key {
890 "build_dir" => config.build_dir.clone(),
891 "state_dir" => config.state_dir.clone(),
892 "state_path" => config.state_path.clone(),
893 "base_path" => config.base_path.clone(),
894 "log_path" => config.log_path.clone(),
895 "socket_path" => config.socket_path.clone(),
896 "error_report_path" => config.error_report_path.clone(),
897 "log_level" => config.log_level.clone(),
898 "trusted_proxy_cidrs" => config.trusted_proxy_cidrs.clone(),
899 "conf_path" => config.conf_path.clone(),
900 _ => None,
901 }
902}
903
904pub fn app_local_conf_value(app_root: &Path, key: &str) -> Option<String> {
925 if key == "app_dir" {
926 return None;
927 }
928
929 let config = load_app_local_config(app_root)?;
930 duplicate_config_field(&config, key)
931}
932
933pub fn load_app_local_config(app_root: &Path) -> Option<AppLocalConfig> {
953 if app_root.as_os_str().is_empty() {
954 return None;
955 }
956
957 let conf_path = app_local_conf_path(app_root)?;
958 let conf_text = read_file_text(&conf_path)?;
959 let manifest = load_manifest_value(app_root);
960
961 let build_dir = resolve_conf_value_key(
962 &conf_path,
963 "build_dir",
964 conf_lookup_value_in_text(&conf_text, "build_dir").as_deref(),
965 );
966 let state_dir = resolve_conf_value_key(
967 &conf_path,
968 "state_dir",
969 conf_lookup_value_in_text(&conf_text, "state_dir").as_deref(),
970 );
971 let state_path = resolve_conf_value_key(
972 &conf_path,
973 "state_path",
974 conf_lookup_value_in_text(&conf_text, "state_path").as_deref(),
975 );
976 let base_path = resolve_conf_value_key(
977 &conf_path,
978 "base_path",
979 conf_lookup_value_in_text(&conf_text, "base_path").as_deref(),
980 );
981 let log_path = resolve_conf_value_key(
982 &conf_path,
983 "log_path",
984 conf_lookup_value_in_text(&conf_text, "log_path").as_deref(),
985 );
986 let socket_path = resolve_conf_value_key(
987 &conf_path,
988 "socket_path",
989 conf_lookup_value_in_text(&conf_text, "socket_path").as_deref(),
990 );
991 let error_report_path = resolve_conf_value_key(
992 &conf_path,
993 "error_report_path",
994 conf_lookup_value_in_text(&conf_text, "error_report_path").as_deref(),
995 );
996 let log_level = resolve_conf_value_key(
997 &conf_path,
998 "log_level",
999 conf_lookup_value_in_text(&conf_text, "log_level").as_deref(),
1000 );
1001 let trusted_proxy_cidrs = resolve_conf_value_key(
1002 &conf_path,
1003 "trusted_proxy_cidrs",
1004 conf_lookup_value_in_text(&conf_text, "trusted_proxy_cidrs").as_deref(),
1005 );
1006
1007 let manifest_build_artifact_dir = manifest
1008 .as_ref()
1009 .and_then(|value| value.get("runtime"))
1010 .and_then(Value::as_object)
1011 .and_then(|runtime| runtime.get("build_artifact_dir"))
1012 .and_then(string_from_json_value);
1013 let manifest_state_path = manifest
1014 .as_ref()
1015 .and_then(|value| value.get("runtime"))
1016 .and_then(Value::as_object)
1017 .and_then(|runtime| runtime.get("state_path"))
1018 .and_then(string_from_json_value);
1019 let manifest_base_path = manifest
1020 .as_ref()
1021 .and_then(|value| value.get("runtime"))
1022 .and_then(Value::as_object)
1023 .and_then(|runtime| runtime.get("base_path"))
1024 .and_then(string_from_json_value);
1025
1026 Some(AppLocalConfig {
1027 conf_path: Some(conf_path.to_string_lossy().into_owned()),
1028 build_dir,
1029 state_dir,
1030 state_path,
1031 base_path,
1032 log_path,
1033 socket_path,
1034 error_report_path,
1035 log_level,
1036 trusted_proxy_cidrs,
1037 manifest_build_artifact_dir,
1038 manifest_state_path,
1039 manifest_base_path,
1040 })
1041}
1042
1043fn normalize_build_dir_value(value: &str) -> Option<String> {
1045 if value.is_empty() {
1046 return None;
1047 }
1048 let mut normalized = PathBuf::new();
1049 let mut previous_was_dist = false;
1050 for component in Path::new(value).components() {
1051 let text = component.as_os_str().to_str()?;
1052 if text == "dist" && previous_was_dist {
1053 continue;
1054 }
1055 previous_was_dist = text == "dist";
1056 normalized.push(component.as_os_str());
1057 }
1058 Some(normalized.to_string_lossy().into_owned())
1059}
1060
1061fn normalized_build_dir_path(path: &Path) -> PathBuf {
1063 normalize_build_dir_value(&path.to_string_lossy())
1064 .map(PathBuf::from)
1065 .unwrap_or_else(|| path.to_path_buf())
1066}
1067
1068pub fn app_build_dir_local(app_root: &Path) -> Option<String> {
1088 let config = load_app_local_config(app_root)?;
1089
1090 if let Some(build_dir) = config.build_dir.filter(|value| !value.is_empty()) {
1091 return normalize_build_dir_value(&build_dir);
1092 }
1093
1094 if let Some(manifest_build_artifact_dir) = config.manifest_build_artifact_dir
1095 && !manifest_build_artifact_dir.is_empty()
1096 && manifest_build_artifact_dir != "null"
1097 && manifest_build_artifact_dir != "."
1098 {
1099 let artifact_path = Path::new(&manifest_build_artifact_dir);
1100 if !artifact_path.is_absolute() {
1101 let mut components = artifact_path.components();
1102 if matches!(
1103 components
1104 .next()
1105 .and_then(|component| component.as_os_str().to_str()),
1106 Some("apps")
1107 ) {
1108 return Some("dist".to_owned());
1109 }
1110 }
1111
1112 let artifact_parent = artifact_path.parent()?;
1113 return artifact_parent
1114 .parent()
1115 .map(|path| path.to_string_lossy().into_owned())
1116 .or_else(|| Some(PathBuf::from(".").to_string_lossy().into_owned()));
1117 }
1118
1119 if app_root
1120 .parent()
1121 .and_then(Path::file_name)
1122 .and_then(|value| value.to_str())
1123 == Some("apps")
1124 && let Some(repo_root) = app_root.parent().and_then(Path::parent)
1125 {
1126 return Some(repo_root.join("dist").to_string_lossy().into_owned());
1127 }
1128
1129 let result = path_dirname(app_root)
1130 .map(|path| path.to_string_lossy().into_owned())
1131 .filter(|value| !value.is_empty());
1132 if result.is_some() {
1133 return result;
1134 }
1135
1136 Some(app_root.join("dist").to_string_lossy().into_owned())
1137}
1138
1139pub fn effective_app_build_dir(
1160 app_root: &Path,
1161 build_dir_override: Option<&str>,
1162) -> Option<String> {
1163 match build_dir_override {
1164 Some(value) if !value.is_empty() => normalize_build_dir_value(value),
1165 _ => app_build_dir_local(app_root),
1166 }
1167}
1168
1169fn path_last_segment_is_apps(path: &Path) -> bool {
1171 path.file_name().is_some_and(|segment| segment == "apps")
1172}
1173
1174fn skip_leading_apps_segment(relative_path: &str) -> &str {
1176 relative_path
1177 .strip_prefix("apps/")
1178 .or_else(|| relative_path.strip_prefix("apps\\"))
1179 .unwrap_or(relative_path)
1180}
1181
1182fn skip_leading_build_dir_segment<'a>(build_dir: &Path, relative_path: &'a str) -> &'a str {
1184 let Some(build_segment) = build_dir.file_name().and_then(|segment| segment.to_str()) else {
1185 return relative_path;
1186 };
1187
1188 let (leading_segment, remainder) = match relative_path.find(['/', '\\']) {
1189 Some(index) => (&relative_path[..index], Some(&relative_path[index + 1..])),
1190 None => (relative_path, None),
1191 };
1192
1193 if leading_segment == build_segment {
1194 remainder.unwrap_or(relative_path)
1195 } else {
1196 relative_path
1197 }
1198}
1199
1200fn resolve_relative_artifact_dir(build_dir: &Path, relative_artifact_dir: &str) -> Option<String> {
1202 if build_dir.as_os_str().is_empty() || relative_artifact_dir.is_empty() {
1203 return None;
1204 }
1205 let build_dir = normalized_build_dir_path(build_dir);
1206
1207 let normalized = if path_last_segment_is_apps(&build_dir) {
1208 skip_leading_apps_segment(relative_artifact_dir)
1209 } else {
1210 skip_leading_build_dir_segment(&build_dir, relative_artifact_dir)
1211 };
1212
1213 Some(build_dir.join(normalized).to_string_lossy().into_owned())
1214}
1215
1216pub fn app_artifact_dir_from_build_dir(build_dir: &Path, app_id: &str) -> Option<String> {
1237 if app_id.is_empty() {
1238 return None;
1239 }
1240 let build_dir = normalized_build_dir_path(build_dir);
1241
1242 if path_last_segment_is_apps(&build_dir) {
1243 return Some(build_dir.join(app_id).to_string_lossy().into_owned());
1244 }
1245
1246 Some(
1247 build_dir
1248 .join("apps")
1249 .join(app_id)
1250 .to_string_lossy()
1251 .into_owned(),
1252 )
1253}
1254
1255pub fn effective_app_artifact_dir(
1277 app_root: &Path,
1278 build_dir: &Path,
1279 app_id: &str,
1280) -> Option<String> {
1281 let config = load_app_local_config(app_root)?;
1282
1283 if let Some(manifest_build_artifact_dir) = config.manifest_build_artifact_dir
1284 && !manifest_build_artifact_dir.is_empty()
1285 && manifest_build_artifact_dir != "null"
1286 && manifest_build_artifact_dir != "."
1287 {
1288 let artifact_path = Path::new(&manifest_build_artifact_dir);
1289 if artifact_path.is_absolute() {
1290 return Some(manifest_build_artifact_dir);
1291 }
1292
1293 return resolve_relative_artifact_dir(build_dir, &manifest_build_artifact_dir);
1294 }
1295
1296 app_artifact_dir_from_build_dir(build_dir, app_id)
1297}
1298
1299pub fn app_state_path_local(app_root: &Path, build_dir: &Path, app_id: &str) -> Option<String> {
1321 let config = load_app_local_config(app_root)?;
1322
1323 if let Some(state_path) = config.state_path.filter(|value| !value.is_empty()) {
1324 return Some(state_path);
1325 }
1326
1327 if let Some(state_dir) = config.state_dir.filter(|value| !value.is_empty()) {
1328 return Some(
1329 Path::new(&state_dir)
1330 .join(app_id)
1331 .join("runtime-state.json")
1332 .to_string_lossy()
1333 .into_owned(),
1334 );
1335 }
1336
1337 if let Some(manifest_state_path) = config.manifest_state_path
1338 && !manifest_state_path.is_empty()
1339 && manifest_state_path != "null"
1340 {
1341 let manifest_state = Path::new(&manifest_state_path);
1342 if manifest_state.is_absolute() {
1343 return Some(manifest_state_path);
1344 }
1345 return Some(app_root.join(manifest_state).to_string_lossy().into_owned());
1346 }
1347
1348 let artifact_dir = effective_app_artifact_dir(app_root, build_dir, app_id)?;
1349 Some(
1350 Path::new(&artifact_dir)
1351 .join("runtime-state.json")
1352 .to_string_lossy()
1353 .into_owned(),
1354 )
1355}
1356
1357pub fn effective_app_state_path(
1381 app_root: &Path,
1382 build_dir: &Path,
1383 app_id: &str,
1384 state_path_override: Option<&str>,
1385 state_dir_override: Option<&str>,
1386) -> Option<String> {
1387 if let Some(state_path_override) = state_path_override.filter(|value| !value.is_empty()) {
1388 return Some(state_path_override.to_owned());
1389 }
1390
1391 if let Some(state_dir_override) = state_dir_override.filter(|value| !value.is_empty()) {
1392 return Some(
1393 Path::new(state_dir_override)
1394 .join(app_id)
1395 .join("runtime-state.json")
1396 .to_string_lossy()
1397 .into_owned(),
1398 );
1399 }
1400
1401 app_state_path_local(app_root, build_dir, app_id)
1402}
1403
1404pub fn parse_serve_config(args: &[String]) -> ServeConfig {
1424 let mut config = ServeConfig {
1425 host: Some("127.0.0.1".to_owned()),
1426 ..ServeConfig::default()
1427 };
1428 let mut index = 0usize;
1429
1430 while index < args.len() {
1431 let arg = args[index].as_str();
1432 if config.kind.is_none() && matches!(arg, "website" | "app" | "runtime") {
1433 config.kind = Some(arg.to_owned());
1434 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
1435 config.target_id = Some(args[index + 1].clone());
1436 index += 2;
1437 continue;
1438 }
1439 index += 1;
1440 continue;
1441 }
1442
1443 let next_value = |index: usize, args: &[String]| args.get(index + 1).cloned();
1444
1445 match arg {
1446 "--host" => {
1447 if let Some(value) = next_value(index, args) {
1448 config.host = Some(value);
1449 index += 2;
1450 continue;
1451 }
1452 }
1453 "--port" => {
1454 if let Some(value) = next_value(index, args) {
1455 config.port = Some(value);
1456 index += 2;
1457 continue;
1458 }
1459 }
1460 "--root" => {
1461 if let Some(value) = next_value(index, args) {
1462 config.root = Some(value);
1463 index += 2;
1464 continue;
1465 }
1466 }
1467 "--conf" => {
1468 if let Some(value) = next_value(index, args) {
1469 config.conf_path = Some(value);
1470 index += 2;
1471 continue;
1472 }
1473 }
1474 "--app-dir" => {
1475 if let Some(value) = next_value(index, args) {
1476 config.app_dir = Some(value);
1477 index += 2;
1478 continue;
1479 }
1480 }
1481 "--build-dir" => {
1482 if let Some(value) = next_value(index, args) {
1483 config.build_dir = Some(value);
1484 index += 2;
1485 continue;
1486 }
1487 }
1488 "--state-dir" => {
1489 if let Some(value) = next_value(index, args) {
1490 config.state_dir = Some(value);
1491 index += 2;
1492 continue;
1493 }
1494 }
1495 "--state-path" => {
1496 if let Some(value) = next_value(index, args) {
1497 config.state_path = Some(value);
1498 index += 2;
1499 continue;
1500 }
1501 }
1502 "--base-path" => {
1503 if let Some(value) = next_value(index, args) {
1504 config.base_path = Some(value);
1505 index += 2;
1506 continue;
1507 }
1508 }
1509 "--log-path" => {
1510 if let Some(value) = next_value(index, args) {
1511 config.log_path = Some(value);
1512 index += 2;
1513 continue;
1514 }
1515 }
1516 "--log-level" => {
1517 if let Some(value) = next_value(index, args) {
1518 config.log_level = Some(value);
1519 index += 2;
1520 continue;
1521 }
1522 }
1523 "--trusted-proxy-cidrs" => {
1524 if let Some(value) = next_value(index, args) {
1525 config.trusted_proxy_cidrs = Some(value);
1526 index += 2;
1527 continue;
1528 }
1529 }
1530 "--socket-path" => {
1531 if let Some(value) = next_value(index, args) {
1532 config.socket_path = Some(value);
1533 index += 2;
1534 continue;
1535 }
1536 }
1537 "--clean" => {
1538 config.clean = true;
1539 index += 1;
1540 continue;
1541 }
1542 "--seed-state" => {
1543 config.seed_state = true;
1544 index += 1;
1545 continue;
1546 }
1547 _ => {}
1548 }
1549
1550 index += 1;
1551 }
1552
1553 config
1554}
1555
1556pub fn free_serve_config(config: &mut ServeConfig) {
1576 *config = ServeConfig::default();
1577}
1578
1579pub fn apply_serve_config_defaults_from_conf(config: &mut ServeConfig) {
1599 if config.conf_path.as_deref().is_none_or(str::is_empty)
1600 && let Some(env_path) = std::env::var_os("PHAROS_CONFIG")
1601 {
1602 let env_path = env_path.to_string_lossy().into_owned();
1603 if !env_path.is_empty() {
1604 config.conf_path = Some(env_path);
1605 }
1606 }
1607
1608 let Some(conf_path) = config.conf_path.clone() else {
1609 return;
1610 };
1611 if conf_path.is_empty() {
1612 return;
1613 }
1614
1615 let cwd = current_working_directory();
1616 let runtime_conf_path = resolve_relative_to(Path::new(&cwd), &conf_path).unwrap_or(conf_path);
1617 let runtime_conf_dir = Path::new(&runtime_conf_path)
1618 .parent()
1619 .map(Path::to_path_buf)
1620 .unwrap_or_else(|| PathBuf::from("."));
1621 let runtime_conf = Path::new(&runtime_conf_path);
1622
1623 if config.socket_path.as_deref().is_none_or(str::is_empty)
1624 && let Some(socket_value) = dynamic_config_lookup_raw(runtime_conf, "socket_path")
1625 && !socket_value.is_empty()
1626 && let Some(resolved) = resolve_relative_to(&runtime_conf_dir, &socket_value)
1627 {
1628 config.socket_path = Some(resolved);
1629 config.socket_path_from_conf = true;
1630 }
1631
1632 if config.port.as_deref().is_none_or(str::is_empty)
1633 && let Some(port_value) = dynamic_config_lookup_raw(runtime_conf, "port")
1634 && !port_value.is_empty()
1635 {
1636 config.port = Some(port_value);
1637 config.port_from_conf = true;
1638 }
1639
1640 if (config.host.as_deref().is_none_or(str::is_empty)
1641 || config.host.as_deref() == Some("127.0.0.1"))
1642 && let Some(host_value) = dynamic_config_lookup_raw(runtime_conf, "host")
1643 && !host_value.is_empty()
1644 {
1645 config.host = Some(host_value);
1646 config.host_from_conf = true;
1647 }
1648
1649 if config
1650 .trusted_proxy_cidrs
1651 .as_deref()
1652 .is_none_or(str::is_empty)
1653 && let Some(proxy_value) = dynamic_config_lookup_raw(runtime_conf, "trusted_proxy_cidrs")
1654 && !proxy_value.is_empty()
1655 {
1656 config.trusted_proxy_cidrs = Some(proxy_value);
1657 config.trusted_proxy_cidrs_from_conf = true;
1658 }
1659
1660 if let Some(value) = dynamic_config_lookup_raw(runtime_conf, "redis_enabled") {
1661 config.redis_enabled = parse_config_bool(Some(value.as_str()), false);
1662 config.redis_enabled_from_conf = true;
1663 }
1664 if config.redis_host.as_deref().is_none_or(str::is_empty)
1665 && let Some(value) = dynamic_config_lookup_raw(runtime_conf, "redis_host")
1666 && !value.is_empty()
1667 {
1668 config.redis_host = Some(value);
1669 config.redis_host_from_conf = true;
1670 }
1671 if config.redis_port.as_deref().is_none_or(str::is_empty)
1672 && let Some(value) = dynamic_config_lookup_raw(runtime_conf, "redis_port")
1673 && !value.is_empty()
1674 {
1675 config.redis_port = Some(value);
1676 config.redis_port_from_conf = true;
1677 }
1678 if config.redis_auth_user.as_deref().is_none_or(str::is_empty)
1679 && let Some(value) = dynamic_config_lookup_raw(runtime_conf, "redis_auth_user")
1680 && !value.is_empty()
1681 {
1682 config.redis_auth_user = Some(value);
1683 config.redis_auth_user_from_conf = true;
1684 }
1685 if config
1686 .redis_auth_token_env
1687 .as_deref()
1688 .is_none_or(str::is_empty)
1689 && let Some(value) = dynamic_config_lookup_raw(runtime_conf, "redis_auth_token_env")
1690 && !value.is_empty()
1691 {
1692 config.redis_auth_token_env = Some(value);
1693 config.redis_auth_token_env_from_conf = true;
1694 }
1695 if config.redis_db_index.as_deref().is_none_or(str::is_empty)
1696 && let Some(value) = dynamic_config_lookup_raw(runtime_conf, "redis_db_index")
1697 && !value.is_empty()
1698 {
1699 config.redis_db_index = Some(value);
1700 config.redis_db_index_from_conf = true;
1701 }
1702 if config
1703 .redis_connect_timeout_ms
1704 .as_deref()
1705 .is_none_or(str::is_empty)
1706 && let Some(value) = dynamic_config_lookup_raw(runtime_conf, "redis_connect_timeout_ms")
1707 && !value.is_empty()
1708 {
1709 config.redis_connect_timeout_ms = Some(value);
1710 config.redis_connect_timeout_ms_from_conf = true;
1711 }
1712 if config
1713 .redis_read_timeout_ms
1714 .as_deref()
1715 .is_none_or(str::is_empty)
1716 && let Some(value) = dynamic_config_lookup_raw(runtime_conf, "redis_read_timeout_ms")
1717 && !value.is_empty()
1718 {
1719 config.redis_read_timeout_ms = Some(value);
1720 config.redis_read_timeout_ms_from_conf = true;
1721 }
1722 if config
1723 .redis_cache_prefix
1724 .as_deref()
1725 .is_none_or(str::is_empty)
1726 && let Some(value) = dynamic_config_lookup_raw(runtime_conf, "redis_cache_prefix")
1727 && !value.is_empty()
1728 {
1729 config.redis_cache_prefix = Some(value);
1730 config.redis_cache_prefix_from_conf = true;
1731 }
1732 if config
1733 .redis_backoff_seconds
1734 .as_deref()
1735 .is_none_or(str::is_empty)
1736 && let Some(value) = dynamic_config_lookup_raw(runtime_conf, "redis_backoff_seconds")
1737 && !value.is_empty()
1738 {
1739 config.redis_backoff_seconds = Some(value);
1740 config.redis_backoff_seconds_from_conf = true;
1741 }
1742 if config
1743 .redis_backoff_max_seconds
1744 .as_deref()
1745 .is_none_or(str::is_empty)
1746 && let Some(value) = dynamic_config_lookup_raw(runtime_conf, "redis_backoff_max_seconds")
1747 && !value.is_empty()
1748 {
1749 config.redis_backoff_max_seconds = Some(value);
1750 config.redis_backoff_max_seconds_from_conf = true;
1751 }
1752 if config.redis_tls_mode.as_deref().is_none_or(str::is_empty)
1753 && let Some(value) = dynamic_config_lookup_raw(runtime_conf, "redis_tls_mode")
1754 && !value.is_empty()
1755 {
1756 config.redis_tls_mode = Some(value);
1757 config.redis_tls_mode_from_conf = true;
1758 }
1759 if config
1760 .redis_tls_root_cert
1761 .as_deref()
1762 .is_none_or(str::is_empty)
1763 && let Some(value) = dynamic_config_lookup_raw(runtime_conf, "redis_tls_root_cert")
1764 && !value.is_empty()
1765 && let Some(resolved) = resolve_relative_to(&runtime_conf_dir, &value)
1766 {
1767 config.redis_tls_root_cert = Some(resolved);
1768 config.redis_tls_root_cert_from_conf = true;
1769 }
1770 if config.redis_tls_cert.as_deref().is_none_or(str::is_empty)
1771 && let Some(value) = dynamic_config_lookup_raw(runtime_conf, "redis_tls_cert")
1772 && !value.is_empty()
1773 && let Some(resolved) = resolve_relative_to(&runtime_conf_dir, &value)
1774 {
1775 config.redis_tls_cert = Some(resolved);
1776 config.redis_tls_cert_from_conf = true;
1777 }
1778 if config.redis_tls_key.as_deref().is_none_or(str::is_empty)
1779 && let Some(value) = dynamic_config_lookup_raw(runtime_conf, "redis_tls_key")
1780 && !value.is_empty()
1781 && let Some(resolved) = resolve_relative_to(&runtime_conf_dir, &value)
1782 {
1783 config.redis_tls_key = Some(resolved);
1784 config.redis_tls_key_from_conf = true;
1785 }
1786 if let Some(value) = dynamic_config_lookup_raw(runtime_conf, "redis_require_peer_name_match") {
1787 config.redis_require_peer_name_match = parse_config_bool(Some(value.as_str()), true);
1788 config.redis_require_peer_name_match_from_conf = true;
1789 }
1790 if let Some(value) = dynamic_config_lookup_raw(runtime_conf, "redis_cache_sessions_enabled") {
1791 config.redis_cache_sessions_enabled = parse_config_bool(Some(value.as_str()), true);
1792 config.redis_cache_sessions_enabled_from_conf = true;
1793 }
1794 if let Some(value) =
1795 dynamic_config_lookup_raw(runtime_conf, "redis_cache_template_source_enabled")
1796 {
1797 config.redis_cache_template_source_enabled = parse_config_bool(Some(value.as_str()), false);
1798 config.redis_cache_template_source_enabled_from_conf = true;
1799 }
1800 if let Some(value) = dynamic_config_lookup_raw(runtime_conf, "redis_cache_route_match_enabled")
1801 {
1802 config.redis_cache_route_match_enabled = parse_config_bool(Some(value.as_str()), false);
1803 config.redis_cache_route_match_enabled_from_conf = true;
1804 }
1805 if let Some(value) =
1806 dynamic_config_lookup_raw(runtime_conf, "redis_cache_config_allowlist_enabled")
1807 {
1808 config.redis_cache_config_allowlist_enabled =
1809 parse_config_bool(Some(value.as_str()), false);
1810 config.redis_cache_config_allowlist_enabled_from_conf = true;
1811 }
1812
1813 if config.log_path.as_deref().is_none_or(str::is_empty)
1814 && let Some(log_path_value) = dynamic_config_lookup_raw(runtime_conf, "log_path")
1815 && !log_path_value.is_empty()
1816 && let Some(resolved) = resolve_relative_to(&runtime_conf_dir, &log_path_value)
1817 {
1818 config.log_path = Some(resolved);
1819 config.log_path_from_conf = true;
1820 }
1821
1822 if config.log_level.as_deref().is_none_or(str::is_empty)
1823 && let Some(level_value) = dynamic_config_lookup_raw(runtime_conf, "log_level")
1824 && !level_value.is_empty()
1825 {
1826 config.log_level = Some(level_value);
1827 config.log_level_from_conf = true;
1828 }
1829}
1830
1831pub fn app_base_path_local(app_root: &Path) -> Option<String> {
1851 let config = load_app_local_config(app_root)?;
1852 let value = config
1853 .base_path
1854 .filter(|value| !value.is_empty())
1855 .or(config.manifest_base_path)
1856 .unwrap_or_default();
1857
1858 normalize_base_path(Some(&value))
1859}
1860
1861pub fn effective_app_base_path(
1882 app_root: &Path,
1883 base_path_override: Option<&str>,
1884) -> Option<String> {
1885 match base_path_override {
1886 Some(value) if !value.is_empty() => normalize_base_path(Some(value)),
1887 _ => app_base_path_local(app_root),
1888 }
1889}
1890
1891pub fn app_log_path_local(app_root: &Path, app_id: &str) -> Option<String> {
1912 let config = load_app_local_config(app_root)?;
1913
1914 if let Some(value) = config.log_path.filter(|value| !value.is_empty()) {
1915 return Some(value);
1916 }
1917
1918 Some(format!("/var/log/pharos/{app_id}.log"))
1919}
1920
1921pub fn effective_app_log_path(
1943 app_root: &Path,
1944 app_id: &str,
1945 log_path_override: Option<&str>,
1946) -> Option<String> {
1947 match log_path_override {
1948 Some(value) if !value.is_empty() => Some(value.to_owned()),
1949 _ => app_log_path_local(app_root, app_id),
1950 }
1951}
1952
1953pub fn app_log_format_local(_app_root: &Path) -> Option<String> {
1973 Some("service".to_owned())
1974}
1975
1976pub fn effective_app_log_format(app_root: &Path) -> Option<String> {
1996 app_log_format_local(app_root)
1997}
1998
1999pub fn app_error_report_path_local(app_root: &Path) -> Option<String> {
2019 let config = load_app_local_config(app_root)?;
2020
2021 if let Some(value) = config.error_report_path.filter(|value| !value.is_empty()) {
2022 return Some(value);
2023 }
2024
2025 Some(String::new())
2026}
2027
2028pub fn effective_app_error_report_path(app_root: &Path, app_id: Option<&str>) -> Option<String> {
2049 let value = app_error_report_path_local(app_root)?;
2050 if !value.is_empty() {
2051 return Some(value);
2052 }
2053
2054 let effective_app_id = app_id.filter(|value| !value.is_empty()).unwrap_or("app");
2055 Some(format!("/var/log/pharos/{effective_app_id}.error.jsonl"))
2056}
2057
2058pub fn app_log_level_local(app_root: &Path) -> Option<String> {
2078 let config = load_app_local_config(app_root)?;
2079
2080 if let Some(value) = config.log_level.filter(|value| !value.is_empty()) {
2081 return Some(value);
2082 }
2083
2084 Some("WARN".to_owned())
2085}
2086
2087pub fn effective_app_log_level(
2108 app_root: &Path,
2109 log_level_override: Option<&str>,
2110) -> Option<String> {
2111 match log_level_override {
2112 Some(value) if !value.is_empty() => Some(value.to_owned()),
2113 _ => app_log_level_local(app_root),
2114 }
2115}
2116
2117pub fn app_log_level_explicit(app_root: &Path) -> Option<String> {
2137 load_app_local_config(app_root)?
2138 .log_level
2139 .filter(|value| !value.is_empty())
2140}
2141
2142pub fn normalize_base_path(raw_value: Option<&str>) -> Option<String> {
2162 let raw_value = match raw_value {
2163 None | Some("") | Some("/") | Some("null") => return Some(String::new()),
2164 Some(value) => value,
2165 };
2166
2167 let trimmed = raw_value.trim_matches('/');
2168 if trimmed.is_empty() {
2169 return Some(String::new());
2170 }
2171
2172 Some(format!("/{trimmed}"))
2173}
2174
2175fn finalize_state_path(
2177 app: &AppRuntime,
2178 config: Option<&ServeConfig>,
2179 app_root: &Path,
2180 build_dir: &Path,
2181) -> Option<String> {
2182 if let Some(config) = config {
2183 if has_cli_override_value(config.state_path.as_deref(), config.state_path_from_conf) {
2184 return config.state_path.clone();
2185 }
2186 if has_cli_override_value(config.state_dir.as_deref(), config.state_dir_from_conf) {
2187 return effective_app_state_path(
2188 app_root,
2189 build_dir,
2190 app.app_id.as_deref()?,
2191 None,
2192 config.state_dir.as_deref(),
2193 );
2194 }
2195 }
2196
2197 let app_local_state_path = app_local_conf_value(app_root, "state_path");
2198 if has_override_value(app_local_state_path.as_deref()) {
2199 return app_local_state_path;
2200 }
2201
2202 let app_local_state_dir = app_local_conf_value(app_root, "state_dir");
2203 if has_override_value(app_local_state_dir.as_deref()) {
2204 return effective_app_state_path(
2205 app_root,
2206 build_dir,
2207 app.app_id.as_deref()?,
2208 None,
2209 app_local_state_dir.as_deref(),
2210 );
2211 }
2212
2213 if has_override_value(app.state_path.as_deref()) {
2214 return app.state_path.clone();
2215 }
2216
2217 if let Some(config) = config {
2218 if has_runtime_default_value(config.state_path.as_deref(), config.state_path_from_conf) {
2219 return config.state_path.clone();
2220 }
2221 if has_runtime_default_value(config.state_dir.as_deref(), config.state_dir_from_conf) {
2222 return effective_app_state_path(
2223 app_root,
2224 build_dir,
2225 app.app_id.as_deref()?,
2226 None,
2227 config.state_dir.as_deref(),
2228 );
2229 }
2230 }
2231
2232 effective_app_state_path(app_root, build_dir, app.app_id.as_deref()?, None, None)
2233}
2234
2235fn finalize_base_path(
2237 app: &AppRuntime,
2238 config: Option<&ServeConfig>,
2239 app_root: &Path,
2240) -> Option<String> {
2241 if let Some(config) = config
2242 && has_cli_override_value(config.base_path.as_deref(), config.base_path_from_conf)
2243 {
2244 return effective_app_base_path(app_root, config.base_path.as_deref());
2245 }
2246
2247 let app_local_base_path = app_local_conf_value(app_root, "base_path");
2248 if app_local_base_path.is_some() {
2249 return effective_app_base_path(app_root, None);
2250 }
2251
2252 if app.base_path.is_some() {
2253 return app.base_path.clone();
2254 }
2255
2256 if let Some(config) = config
2257 && has_runtime_default_value(config.base_path.as_deref(), config.base_path_from_conf)
2258 {
2259 return effective_app_base_path(app_root, config.base_path.as_deref());
2260 }
2261
2262 effective_app_base_path(app_root, None)
2263}
2264
2265fn finalize_log_path(
2267 app: &AppRuntime,
2268 config: Option<&ServeConfig>,
2269 app_root: &Path,
2270) -> Option<String> {
2271 let app_local_log_path = app
2272 .app_id
2273 .as_deref()
2274 .and_then(|app_id| app_log_path_local(app_root, app_id));
2275 if let Some(config) = config
2276 && has_cli_override_value(config.log_path.as_deref(), config.log_path_from_conf)
2277 {
2278 return effective_app_log_path(
2279 app_root,
2280 app.app_id.as_deref()?,
2281 config.log_path.as_deref(),
2282 );
2283 }
2284
2285 if has_override_value(app_local_log_path.as_deref()) {
2286 return app_local_log_path;
2287 }
2288
2289 if has_override_value(app.log_path.as_deref()) {
2290 return app.log_path.clone();
2291 }
2292
2293 if let Some(config) = config
2294 && has_runtime_default_value(config.log_path.as_deref(), config.log_path_from_conf)
2295 {
2296 return config.log_path.clone();
2297 }
2298
2299 app_local_log_path
2300}
2301
2302fn finalize_log_format(app: &AppRuntime, app_root: &Path) -> Option<String> {
2304 if has_override_value(app.log_format.as_deref()) {
2305 return app.log_format.clone();
2306 }
2307
2308 effective_app_log_format(app_root)
2309}
2310
2311fn finalize_error_report_path(app: &AppRuntime, app_root: &Path) -> Option<String> {
2313 let app_local_error_report_path = app_error_report_path_local(app_root);
2314 if has_override_value(app_local_error_report_path.as_deref()) {
2315 return app_local_error_report_path;
2316 }
2317
2318 if app.error_report_path.is_some() {
2319 return app.error_report_path.clone();
2320 }
2321
2322 effective_app_error_report_path(app_root, app.app_id.as_deref())
2323}
2324
2325fn finalize_log_level(config: Option<&ServeConfig>, app_root: &Path) -> Option<String> {
2327 if let Some(config) = config
2328 && has_cli_override_value(config.log_level.as_deref(), config.log_level_from_conf)
2329 {
2330 return config.log_level.clone();
2331 }
2332
2333 if let Some(app_value) = app_log_level_explicit(app_root) {
2334 return Some(app_value);
2335 }
2336
2337 if let Some(config) = config
2338 && has_runtime_default_value(config.log_level.as_deref(), config.log_level_from_conf)
2339 {
2340 return config.log_level.clone();
2341 }
2342
2343 Some("WARN".to_owned())
2344}
2345
2346fn finalize_trusted_proxy_cidrs(app: &AppRuntime, config: Option<&ServeConfig>) -> Option<String> {
2348 if let Some(config) = config
2349 && has_cli_override_value(
2350 config.trusted_proxy_cidrs.as_deref(),
2351 config.trusted_proxy_cidrs_from_conf,
2352 )
2353 {
2354 return config.trusted_proxy_cidrs.clone();
2355 }
2356
2357 if has_override_value(app.trusted_proxy_cidrs.as_deref()) {
2358 return app.trusted_proxy_cidrs.clone();
2359 }
2360
2361 if let Some(config) = config
2362 && has_runtime_default_value(
2363 config.trusted_proxy_cidrs.as_deref(),
2364 config.trusted_proxy_cidrs_from_conf,
2365 )
2366 {
2367 return config.trusted_proxy_cidrs.clone();
2368 }
2369
2370 None
2371}
2372
2373pub fn resolved_runtime_conf_path_from_config(
2394 config: Option<&ServeConfig>,
2395 app_root: &Path,
2396) -> Option<String> {
2397 if let Some(config) = config
2398 && let Some(conf_path) = config
2399 .conf_path
2400 .as_deref()
2401 .filter(|value| !value.is_empty())
2402 {
2403 return resolve_relative_to(app_root, conf_path).or_else(|| Some(conf_path.to_owned()));
2404 }
2405
2406 app_local_conf_path(app_root).map(|path| path.to_string_lossy().into_owned())
2407}
2408
2409fn finalize_runtime_conf_path(
2411 app: &AppRuntime,
2412 config: Option<&ServeConfig>,
2413 app_root: &Path,
2414) -> Option<String> {
2415 if let Some(config) = config
2416 && has_override_value(config.conf_path.as_deref())
2417 {
2418 return resolved_runtime_conf_path_from_config(Some(config), app_root);
2419 }
2420
2421 if has_override_value(app.runtime_conf_path.as_deref()) {
2422 return app.runtime_conf_path.clone();
2423 }
2424
2425 resolved_runtime_conf_path_from_config(config, app_root)
2426}
2427
2428fn finalize_queue_enabled(app_root: &Path) -> bool {
2430 parse_queue_enabled(dynamic_config_effective_value(app_root, None, "queue_enabled").as_deref())
2431}
2432
2433fn finalize_queue_long_field(app_root: &Path, key: &str, default_value: i64) -> i64 {
2435 parse_positive_i64(
2436 dynamic_config_effective_value(app_root, None, key).as_deref(),
2437 default_value,
2438 )
2439}
2440
2441fn resource_limit(
2443 app_root: &Path,
2444 runtime_conf_path: Option<&Path>,
2445 key: &str,
2446 default_value: usize,
2447 maximum: usize,
2448) -> usize {
2449 dynamic_config_effective_value(app_root, runtime_conf_path, key)
2450 .and_then(|value| value.parse::<usize>().ok())
2451 .filter(|value| *value > 0 && *value <= maximum)
2452 .unwrap_or(default_value)
2453}
2454
2455fn finalize_resource_budgets(app_root: &Path, runtime_conf_path: Option<&Path>) -> ResourceBudgets {
2457 let defaults = ResourceBudgets::default();
2458 ResourceBudgets {
2459 max_response_bytes: resource_limit(
2460 app_root,
2461 runtime_conf_path,
2462 "max_response_bytes",
2463 defaults.max_response_bytes,
2464 64 * 1024 * 1024,
2465 ),
2466 max_query_result_bytes: resource_limit(
2467 app_root,
2468 runtime_conf_path,
2469 "max_query_result_bytes",
2470 defaults.max_query_result_bytes,
2471 64 * 1024 * 1024,
2472 ),
2473 max_report_bytes: resource_limit(
2474 app_root,
2475 runtime_conf_path,
2476 "max_report_bytes",
2477 defaults.max_report_bytes,
2478 64 * 1024 * 1024,
2479 ),
2480 max_state_section_items: resource_limit(
2481 app_root,
2482 runtime_conf_path,
2483 "max_state_section_items",
2484 defaults.max_state_section_items,
2485 1_000_000,
2486 ),
2487 max_workflow_effects: resource_limit(
2488 app_root,
2489 runtime_conf_path,
2490 "max_workflow_effects",
2491 defaults.max_workflow_effects,
2492 100_000,
2493 ),
2494 max_jobs_per_poll: resource_limit(
2495 app_root,
2496 runtime_conf_path,
2497 "max_jobs_per_poll",
2498 defaults.max_jobs_per_poll,
2499 10_000,
2500 ),
2501 integration_connect_timeout_ms: resource_limit(
2502 app_root,
2503 runtime_conf_path,
2504 "integration_connect_timeout_ms",
2505 defaults.integration_connect_timeout_ms as usize,
2506 120_000,
2507 ) as u64,
2508 integration_operation_timeout_ms: resource_limit(
2509 app_root,
2510 runtime_conf_path,
2511 "integration_operation_timeout_ms",
2512 defaults.integration_operation_timeout_ms as usize,
2513 300_000,
2514 ) as u64,
2515 integration_max_response_bytes: resource_limit(
2516 app_root,
2517 runtime_conf_path,
2518 "integration_max_response_bytes",
2519 defaults.integration_max_response_bytes,
2520 16 * 1024 * 1024,
2521 ),
2522 }
2523}
2524
2525fn application_effects_within_budget(app: &AppRuntime, budgets: &ResourceBudgets) -> bool {
2527 app.application_ir.as_ref().is_none_or(|ir| {
2528 ir.actions.values().all(|action| {
2529 action
2530 .effects
2531 .reads
2532 .len()
2533 .saturating_add(action.effects.writes.len())
2534 .saturating_add(action.effects.emits.len())
2535 .saturating_add(action.effects.enqueues.len())
2536 .saturating_add(action.effects.external_calls.len())
2537 <= budgets.max_workflow_effects
2538 })
2539 })
2540}
2541
2542fn state_sections_within_budget(path: &Path, budgets: &ResourceBudgets) -> bool {
2544 if !path.is_file() {
2545 return true;
2546 }
2547 let Ok(metadata) = fs::metadata(path) else {
2548 return false;
2549 };
2550 if metadata.len() > budgets.max_query_result_bytes as u64 {
2551 return false;
2552 }
2553 fs::read_to_string(path)
2554 .ok()
2555 .and_then(|text| serde_json::from_str::<Value>(&text).ok())
2556 .and_then(|value| value.as_object().cloned())
2557 .is_some_and(|object| {
2558 object.values().all(|value| {
2559 value
2560 .as_array()
2561 .is_none_or(|rows| rows.len() <= budgets.max_state_section_items)
2562 })
2563 })
2564}
2565
2566pub fn finalize_loaded_app(
2594 app: &mut AppRuntime,
2595 config: Option<&ServeConfig>,
2596 app_root_override: Option<&Path>,
2597 build_dir_override: Option<&Path>,
2598) -> Result<(), &'static str> {
2599 let app_root_value = app_root_override
2600 .map(|path| path.to_string_lossy().into_owned())
2601 .or_else(|| app.app_root.clone())
2602 .ok_or("missing app_root")?;
2603 let build_dir_value = build_dir_override
2604 .map(|path| path.to_string_lossy().into_owned())
2605 .or_else(|| app.build_dir.clone())
2606 .ok_or("missing build_dir")?;
2607
2608 let app_root_path = PathBuf::from(&app_root_value);
2609 let build_dir_path = PathBuf::from(&build_dir_value);
2610 let state_path = finalize_state_path(app, config, &app_root_path, &build_dir_path)
2611 .ok_or("missing state_path")?;
2612 let base_path = finalize_base_path(app, config, &app_root_path).unwrap_or_default();
2613 let log_path = finalize_log_path(app, config, &app_root_path).ok_or("missing log_path")?;
2614 let log_format = finalize_log_format(app, &app_root_path).ok_or("missing log_format")?;
2615 let error_report_path =
2616 finalize_error_report_path(app, &app_root_path).ok_or("missing error_report_path")?;
2617 let log_level = finalize_log_level(config, &app_root_path).unwrap_or_else(|| "WARN".to_owned());
2618 let trusted_proxy_cidrs = finalize_trusted_proxy_cidrs(app, config);
2619 let runtime_conf_path = finalize_runtime_conf_path(app, config, &app_root_path);
2620 let resource_budgets =
2621 finalize_resource_budgets(&app_root_path, runtime_conf_path.as_deref().map(Path::new));
2622 if !application_effects_within_budget(app, &resource_budgets)
2623 || !state_sections_within_budget(Path::new(&state_path), &resource_budgets)
2624 {
2625 return Err("runtime resource budget exceeded");
2626 }
2627
2628 app.app_root = Some(app_root_value);
2629 app.build_dir = Some(build_dir_value);
2630 app.state_path = Some(state_path);
2631 app.base_path = Some(base_path);
2632 app.log_path = Some(log_path);
2633 app.log_format = Some(log_format);
2634 app.error_report_path = Some(error_report_path);
2635 app.log_level = Some(log_level);
2636 app.trusted_proxy_cidrs = trusted_proxy_cidrs;
2637 app.runtime_conf_path = runtime_conf_path;
2638 app.queue_enabled = finalize_queue_enabled(&app_root_path);
2639 app.queue_poll_interval_ms =
2640 finalize_queue_long_field(&app_root_path, "queue_poll_interval_ms", 5000);
2641 app.queue_max_attempts = finalize_queue_long_field(&app_root_path, "queue_max_attempts", 3);
2642 app.queue_retry_seconds = finalize_queue_long_field(&app_root_path, "queue_retry_seconds", 30);
2643 app.resource_budgets = resource_budgets;
2644
2645 Ok(())
2646}
2647
2648pub fn free_app_local_config(config: &mut AppLocalConfig) {
2668 *config = AppLocalConfig::default();
2669}
2670
2671pub fn resolve_relative_artifact_dir_runtime_local(
2692 build_dir: &Path,
2693 artifact_dir: &str,
2694) -> Option<String> {
2695 if build_dir.as_os_str().is_empty()
2696 || artifact_dir.is_empty()
2697 || artifact_dir.contains(['\0', '\r', '\n'])
2698 {
2699 return None;
2700 }
2701 let relative = Path::new(artifact_dir);
2702 if relative.is_absolute()
2703 || relative
2704 .components()
2705 .any(|component| matches!(component, std::path::Component::ParentDir))
2706 {
2707 return None;
2708 }
2709 Some(
2710 build_dir
2711 .join(relative)
2712 .to_string_lossy()
2713 .replace("dist/dist/", "dist/"),
2714 )
2715}
2716
2717pub fn path_last_segment_is_apps_runtime_local(path: &Path) -> bool {
2737 path.file_name().and_then(|value| value.to_str()) == Some("apps")
2738}
2739
2740pub fn skip_leading_apps_segment_runtime_local(path: &Path) -> PathBuf {
2760 let mut components = path.components();
2761 if matches!(components.next(), Some(std::path::Component::Normal(value)) if value == "apps") {
2762 components.as_path().to_path_buf()
2763 } else {
2764 path.to_path_buf()
2765 }
2766}
2767
2768#[cfg(test)]
2769mod tests {
2770 use std::fs;
2771 use std::path::Path;
2772
2773 #[test]
2774 fn app_local_state_path_resolves_relative_to_app_root() {
2775 let root =
2776 std::env::temp_dir().join(format!("pharos-config-state-path-{}", std::process::id()));
2777 let app_root = root.join("apps").join("architect");
2778 fs::create_dir_all(&app_root).expect("app root");
2779 fs::write(
2780 app_root.join("app.conf"),
2781 "base_path=/architect\nstate_path=var/state/runtime-state.json\n",
2782 )
2783 .expect("app conf");
2784
2785 let resolved = super::app_state_path_local(&app_root, Path::new("dist"), "architect")
2786 .expect("state path");
2787
2788 assert_eq!(
2789 resolved,
2790 app_root
2791 .join("var/state/runtime-state.json")
2792 .to_string_lossy()
2793 .into_owned()
2794 );
2795
2796 fs::remove_dir_all(root).expect("cleanup");
2797 }
2798}