1use std::collections::HashMap;
2use std::sync::Arc;
3
4use algocline_core::pkg::{PkgType, TypeSource};
5use algocline_core::QueryId;
6use algocline_engine::{FeedResult, VariantPkg};
7
8use super::alc_toml::load_alc_toml;
9use super::eval_store::{splice_response_string, splice_response_warnings};
10use super::resolve::{is_package_installed, make_require_code, resolve_code, QueryResponse};
11use super::transcript::write_transcript_log;
12use super::AppService;
13use crate::pool::dispatch::{continue_via_pool, run_via_pool};
14
15pub(crate) fn normalize_stringified_json_object(v: serde_json::Value) -> serde_json::Value {
26 match v {
27 serde_json::Value::String(ref s) => match serde_json::from_str::<serde_json::Value>(s) {
28 Ok(parsed @ serde_json::Value::Object(_)) => parsed,
29 Ok(parsed @ serde_json::Value::Array(_)) => parsed,
30 _ => v,
31 },
32 other => other,
33 }
34}
35
36fn splice_save_warning(result_json: &str, warning: Option<String>) -> String {
40 match warning {
41 Some(msg) => splice_response_string(result_json, "save_warning", &msg),
42 None => result_json.to_string(),
43 }
44}
45
46fn splice_transcript_warning(result_json: &str, warning: Option<String>) -> String {
50 match warning {
51 Some(msg) => splice_response_string(result_json, "transcript_warning", &msg),
52 None => result_json.to_string(),
53 }
54}
55
56pub(super) fn resolve_env(
81 ctx: &serde_json::Value,
82 project_root: Option<&std::path::Path>,
83 alc_toml_allow: Option<&[String]>,
84) -> Result<Arc<HashMap<String, String>>, String> {
85 let env_obj = ctx.get("env").and_then(|v| v.as_object());
86
87 let inject: HashMap<String, String> = if let Some(obj) = env_obj {
89 if let Some(inject_val) = obj.get("inject") {
90 match inject_val.as_object() {
91 Some(m) => {
92 let mut map = HashMap::new();
93 for (k, v) in m {
94 match v.as_str() {
95 Some(s) => {
96 map.insert(k.clone(), s.to_string());
97 }
98 None => {
99 return Err(format!(
100 "ctx.env.inject: value for key '{k}' must be a string, got {v}"
101 ));
102 }
103 }
104 }
105 map
106 }
107 None => {
108 return Err(format!(
109 "ctx.env.inject must be an object, got {}",
110 inject_val
111 ));
112 }
113 }
114 } else {
115 HashMap::new()
116 }
117 } else {
118 HashMap::new()
119 };
120
121 let dotenv_path: Option<std::path::PathBuf> = if let Some(p) = env_obj
123 .and_then(|o| o.get("dotenv"))
124 .and_then(|v| v.as_str())
125 {
126 let path = std::path::Path::new(p);
127 if path.is_absolute() {
128 Some(path.to_path_buf())
129 } else {
130 match project_root {
131 Some(root) => Some(root.join(p)),
132 None => {
133 return Err(format!(
134 "ctx.env.dotenv: relative path '{p}' requires project_root to be set"
135 ));
136 }
137 }
138 }
139 } else {
140 None
141 };
142
143 let allow_os = env_obj
144 .and_then(|o| o.get("allow_os"))
145 .and_then(|v| v.as_bool())
146 .unwrap_or(false);
147
148 let mut merged: HashMap<String, String> = HashMap::new();
149
150 if allow_os {
152 for (k, v) in std::env::vars() {
153 merged.insert(k, v);
154 }
155 }
156
157 if let Some(ref full) = dotenv_path {
159 let iter = dotenvy::from_path_iter(full)
160 .map_err(|e| format!("ctx.env.dotenv: failed to open '{}': {e}", full.display()))?;
161 for item in iter {
162 let (k, v) = item
163 .map_err(|e| format!("ctx.env.dotenv: parse error in '{}': {e}", full.display()))?;
164 merged.insert(k, v);
165 }
166 }
167
168 for (k, v) in inject {
170 merged.insert(k, v);
171 }
172
173 if let Some(allow) = alc_toml_allow {
175 if !allow.is_empty() {
176 let allowset: std::collections::HashSet<&String> = allow.iter().collect();
177 merged.retain(|k, _| allowset.contains(k));
178 }
179 }
180
181 Ok(Arc::new(merged))
182}
183
184impl AppService {
185 pub async fn run(
205 &self,
206 code: Option<String>,
207 code_file: Option<String>,
208 ctx: Option<serde_json::Value>,
209 project_root: Option<String>,
210 host_mode: Option<bool>,
211 ) -> Result<String, String> {
212 let code = resolve_code(code, code_file)?;
213 let ctx = normalize_stringified_json_object(ctx.unwrap_or(serde_json::Value::Null));
214 let (extra, extra_warnings) = self.resolve_extra_lib_paths(project_root.as_deref());
215 let (variants, variant_warnings) = self.resolve_variant_pkgs(project_root.as_deref());
216 let mut warnings: Vec<String> = extra_warnings;
217 warnings.extend(variant_warnings);
218
219 if host_mode == Some(true) {
220 let (session_id, json, pool_save_error) = run_via_pool(
224 &self.pool_dir,
225 &self.pool_reg_path,
226 &self.pool_lock_path,
227 extra,
228 code,
229 ctx,
230 )
231 .await
232 .map_err(|e| e.to_string())?;
233
234 let cache_reload_warning: Option<String> =
244 match crate::pool::PoolRegistry::load_or_default(&self.pool_reg_path) {
245 Ok(reg) => {
246 let mut guard = self.pool_registry.write().await;
247 *guard = reg;
248 None
249 }
250 Err(e) => {
251 tracing::warn!(
252 error = %e,
253 "failed to reload pool registry after run; in-memory cache may be stale"
254 );
255 Some(e.to_string())
256 }
257 };
258
259 let json = splice_response_warnings(&json, "lib_path_warnings", &warnings);
260 let json = match pool_save_error {
261 Some(msg) => splice_response_string(&json, "pool_save_error", &msg),
262 None => json,
263 };
264 let json = match cache_reload_warning {
265 Some(msg) => splice_response_string(&json, "pool_cache_reload_warning", &msg),
266 None => json,
267 };
268 let _ = session_id; return Ok(json);
270 }
271
272 let alc_toml_allow_list: Vec<String> = if let Some(root) = project_root.as_deref() {
277 let root_path = std::path::Path::new(root);
278 match load_alc_toml(root_path) {
279 Ok(Some(t)) => t.env.map(|e| e.allow).unwrap_or_default(),
280 Ok(None) => Vec::new(),
281 Err(e) => return Err(format!("alc.toml load error: {e}")),
282 }
283 } else {
284 Vec::new()
285 };
286 let alc_toml_allow = if alc_toml_allow_list.is_empty() {
287 None
288 } else {
289 Some(alc_toml_allow_list.as_slice())
290 };
291
292 let project_root_path = project_root.as_deref().map(std::path::Path::new);
293 let env_map = resolve_env(&ctx, project_root_path, alc_toml_allow)?;
294
295 let json = self
296 .start_and_tick(env_map, code, ctx, None, extra, variants)
297 .await?;
298 Ok(splice_response_warnings(
299 &json,
300 "lib_path_warnings",
301 &warnings,
302 ))
303 }
304
305 pub async fn advice(
313 &self,
314 strategy: &str,
315 task: Option<String>,
316 opts: Option<serde_json::Value>,
317 project_root: Option<String>,
318 ) -> Result<String, String> {
319 let app_dir = self.log_config.app_dir();
322 let (variants, variant_warnings) = self.resolve_variant_pkgs(project_root.as_deref());
323 let strategy_in_variant = variants.iter().any(|v| v.name == strategy);
324
325 if !strategy_in_variant && !is_package_installed(&app_dir, strategy) {
327 self.auto_install_bundled_packages().await?;
328 if !is_package_installed(&app_dir, strategy) {
329 return Err(format!(
330 "Package '{strategy}' not found after installing bundled collection. \
331 Use alc_pkg_install to install it manually."
332 ));
333 }
334 }
335
336 if let Some((PkgType::Library, _)) = self.resolve_pkg_type_lua(strategy, &variants).await? {
338 return Err(format!(
339 "Package '{strategy}' is a library package (type = \"library\"). \
340 Library packages provide reusable modules and do not have a run() entry point. \
341 Use alc_run with custom code to import this library."
342 ));
343 }
344
345 let code = make_require_code(strategy);
346
347 let opts = opts.map(normalize_stringified_json_object);
348 let mut ctx_map = match opts {
349 Some(serde_json::Value::Object(m)) => m,
350 _ => serde_json::Map::new(),
351 };
352 if let Some(task) = task {
353 ctx_map.insert("task".into(), serde_json::Value::String(task));
354 }
355 let ctx = serde_json::Value::Object(ctx_map);
356
357 let (extra, extra_warnings) = self.resolve_extra_lib_paths(project_root.as_deref());
358 let mut warnings: Vec<String> = extra_warnings;
359 warnings.extend(variant_warnings);
360 let env_map = Arc::new(HashMap::new());
363 let json = self
364 .start_and_tick(env_map, code, ctx, Some(strategy), extra, variants)
365 .await?;
366 Ok(splice_response_warnings(
367 &json,
368 "lib_path_warnings",
369 &warnings,
370 ))
371 }
372
373 pub(crate) async fn resolve_pkg_type_lua(
401 &self,
402 name: &str,
403 variants: &[VariantPkg],
404 ) -> Result<Option<(PkgType, TypeSource)>, String> {
405 let auto = super::resolve::LUA_TYPE_AUTODETECT;
406 let code = format!(
407 r#"package.loaded["{name}"] = nil; local pkg = require("{name}"); local meta = pkg.meta or {{}}; {auto}; return {{ type = meta.type, type_source = meta.type_source }}"#,
408 name = name,
409 auto = auto,
410 );
411 let val = self
412 .executor
413 .eval_simple_with_paths(code, vec![], variants.to_vec())
414 .await?;
415 let result = val.as_object().and_then(|obj| {
416 let pkg_type =
417 obj.get("type")
418 .and_then(|v| v.as_str())
419 .and_then(|s| match s.parse::<PkgType>() {
420 Ok(t) => Some(t),
421 Err(e) => {
422 tracing::warn!(
423 package = name,
424 raw_type = s,
425 error = %e,
426 "unknown pkg type string; treating as legacy passthrough"
427 );
428 None
429 }
430 });
431 let type_source = obj
432 .get("type_source")
433 .and_then(|v| v.as_str())
434 .and_then(|s| s.parse::<TypeSource>().ok());
435 match (pkg_type, type_source) {
436 (Some(t), Some(src)) => Some((t, src)),
437 _ => None,
438 }
439 });
440 Ok(result)
441 }
442
443 pub async fn continue_batch(
450 &self,
451 session_id: &str,
452 responses: Vec<QueryResponse>,
453 ) -> Result<String, String> {
454 let pool_entry = {
456 let reg = self.pool_registry.read().await;
457 reg.find(session_id).cloned()
458 };
459
460 let pool_entry = if pool_entry.is_some() {
461 pool_entry
462 } else {
463 match crate::pool::PoolRegistry::load_or_default(&self.pool_reg_path) {
464 Ok(reg) => {
465 let entry = reg.find(session_id).cloned();
466 if entry.is_some() {
467 let mut guard = self.pool_registry.write().await;
468 *guard = reg;
469 }
470 entry
471 }
472 Err(e) => {
473 return Err(format!("Continue failed: {e}"));
474 }
475 }
476 };
477
478 if let Some(entry) = pool_entry {
479 let mut last_json = None;
481 for qr in responses {
482 let json =
483 continue_via_pool(&entry, session_id, qr.response, Some(qr.query_id), qr.usage)
484 .await
485 .map_err(|e| format!("Continue failed: {e}"))?;
486 last_json = Some(json);
487 }
488 return last_json.ok_or_else(|| "Empty responses array".to_string());
489 }
490
491 let mut last_result = None;
493 for qr in responses {
494 let qid = QueryId::parse(&qr.query_id);
495 let result = self
496 .registry
497 .feed_response(session_id, &qid, qr.response, qr.usage.as_ref())
498 .await
499 .map_err(|e| format!("Continue failed: {e}"))?;
500 last_result = Some(result);
501 }
502 let result = last_result.ok_or("Empty responses array")?;
503 let transcript_warning = self.maybe_log_transcript(&result, session_id);
504 let json = result.to_json(session_id).to_string();
505 let json = splice_transcript_warning(&json, transcript_warning);
506 let save_warning = self.maybe_save_eval(&result, session_id, &json);
507 Ok(splice_save_warning(&json, save_warning))
508 }
509
510 pub async fn continue_single(
531 &self,
532 session_id: &str,
533 response: String,
534 query_id: Option<&str>,
535 usage: Option<algocline_core::TokenUsage>,
536 ) -> Result<String, String> {
537 let pool_entry = {
540 let reg = self.pool_registry.read().await;
541 reg.find(session_id).cloned()
542 }; let pool_entry = if pool_entry.is_some() {
546 pool_entry
547 } else {
548 match crate::pool::PoolRegistry::load_or_default(&self.pool_reg_path) {
549 Ok(reg) => {
550 let entry = reg.find(session_id).cloned();
551 if entry.is_some() {
552 let mut guard = self.pool_registry.write().await;
554 *guard = reg;
555 }
556 entry
557 }
558 Err(e) => {
559 return Err(format!("Continue failed: {e}"));
561 }
562 }
563 };
564
565 if let Some(entry) = pool_entry {
566 let json = continue_via_pool(
568 &entry,
569 session_id,
570 response,
571 query_id.map(str::to_string),
572 usage,
573 )
574 .await
575 .map_err(|e| format!("Continue failed: {e}"))?;
576 return Ok(json);
577 }
578
579 let query_id = match query_id {
581 Some(qid) => QueryId::parse(qid),
582 None => self
583 .registry
584 .resolve_sole_pending_id(session_id)
585 .await
586 .map_err(|e| format!("Continue failed: {e}"))?,
587 };
588
589 let result = self
590 .registry
591 .feed_response(session_id, &query_id, response, usage.as_ref())
592 .await
593 .map_err(|e| format!("Continue failed: {e}"))?;
594
595 let transcript_warning = self.maybe_log_transcript(&result, session_id);
596 let json = result.to_json(session_id).to_string();
597 let json = splice_transcript_warning(&json, transcript_warning);
598 let save_warning = self.maybe_save_eval(&result, session_id, &json);
599 Ok(splice_save_warning(&json, save_warning))
600 }
601
602 pub(super) fn maybe_log_transcript(
605 &self,
606 result: &FeedResult,
607 session_id: &str,
608 ) -> Option<String> {
609 if let FeedResult::Finished(exec_result) = result {
610 let strategy = match self.session_strategies.lock() {
615 Ok(mut map) => map.remove(session_id),
616 Err(e) => {
617 tracing::warn!(
618 "session_strategies mutex poisoned for '{}': {}",
619 session_id,
620 e
621 );
622 return Some(format!(
625 "session_strategies mutex poisoned for '{session_id}': {e}"
626 ));
627 }
628 };
629 match write_transcript_log(
633 &self.log_config,
634 session_id,
635 &exec_result.metrics,
636 strategy.as_deref(),
637 ) {
638 Err(e) => Some(e.to_string()),
639 Ok(meta_warning) => meta_warning,
640 }
641 } else {
642 None
643 }
644 }
645
646 pub(super) fn maybe_save_eval(
652 &self,
653 result: &FeedResult,
654 session_id: &str,
655 result_json: &str,
656 ) -> Option<String> {
657 if !matches!(result, FeedResult::Finished(_)) {
658 return None;
659 }
660 let strategy = {
661 let mut map = self.eval_sessions.lock().unwrap_or_else(|e| e.into_inner());
662 map.remove(session_id)
663 };
664 strategy.and_then(|s| {
665 super::eval_store::save_eval_result(&self.log_config.app_dir(), &s, result_json).err()
666 })
667 }
668
669 pub(super) async fn start_and_tick(
687 &self,
688 env_map: Arc<HashMap<String, String>>,
689 code: String,
690 ctx: serde_json::Value,
691 strategy: Option<&str>,
692 extra_lib_paths: Vec<std::path::PathBuf>,
693 variant_pkgs: Vec<VariantPkg>,
694 ) -> Result<String, String> {
695 let app_dir = self.log_config.app_dir();
696 let dirs = algocline_engine::SessionDirs {
697 state_store: Arc::clone(&self.state_store),
698 card_store: Arc::clone(&self.card_store),
699 scenarios_dir: app_dir.scenarios_dir(),
700 nn_dir: app_dir.nn_dir(),
701 };
702 let card_run_enabled = false;
709 let session = self
710 .executor
711 .start_session_with_env(
712 env_map,
713 code,
714 ctx,
715 extra_lib_paths,
716 variant_pkgs,
717 dirs,
718 card_run_enabled,
719 )
720 .await?;
721 let (session_id, result) = self
722 .registry
723 .start_execution(session)
724 .await
725 .map_err(|e| format!("Execution failed: {e}"))?;
726 if let Some(s) = strategy {
727 if let Ok(mut map) = self.session_strategies.lock() {
728 map.insert(session_id.clone(), s.to_string());
729 }
730 }
731 let transcript_warning = self.maybe_log_transcript(&result, &session_id);
732 let json = result.to_json(&session_id).to_string();
733 Ok(splice_transcript_warning(&json, transcript_warning))
734 }
735}
736
737#[cfg(test)]
738mod tests {
739 use std::path::PathBuf;
740 use std::sync::Arc;
741
742 use algocline_core::{
743 AppDir, ExecutionMetrics, ExecutionObserver, LlmQuery, QueryId, TerminalState,
744 };
745 use algocline_engine::{ExecutionResult, FeedResult};
746
747 use super::super::config::{AppConfig, LogDirSource};
748 use super::{splice_transcript_warning, AppService};
749
750 fn make_metrics_with_transcript() -> ExecutionMetrics {
751 let metrics = ExecutionMetrics::new();
752 let observer = metrics.create_observer();
753 observer.on_paused(&[LlmQuery {
754 id: QueryId::single(),
755 prompt: "test prompt".into(),
756 system: None,
757 max_tokens: 100,
758 grounded: false,
759 underspecified: false,
760 cache_breakpoint: None,
761 role: None,
762 }]);
763 metrics
764 }
765
766 fn make_finished_result(metrics: ExecutionMetrics) -> FeedResult {
767 FeedResult::Finished(ExecutionResult {
768 state: TerminalState::Completed {
769 result: serde_json::json!({"ok": true}),
770 },
771 metrics,
772 })
773 }
774
775 async fn make_app_service_with_log_dir(log_dir: PathBuf) -> AppService {
777 let executor = Arc::new(
778 algocline_engine::Executor::new(vec![])
779 .await
780 .expect("executor"),
781 );
782 let tmp_app = tempfile::tempdir().expect("test tempdir");
783 let log_config = AppConfig {
784 log_dir: Some(log_dir),
785 log_dir_source: LogDirSource::EnvVar,
786 log_enabled: true,
787 prompt_preview_chars: 200,
788 app_dir: Arc::new(AppDir::new(tmp_app.path().to_path_buf())),
789 };
790 std::mem::forget(tmp_app);
791 AppService::new(executor, log_config, vec![])
792 }
793
794 #[tokio::test]
797 async fn maybe_log_transcript_returns_some_on_write_failure() {
798 let tmp = tempfile::tempdir().expect("test tempdir");
799 let log_dir = tmp.path().to_path_buf();
800 std::fs::create_dir_all(log_dir.join("fail-session.json"))
802 .expect("pre-create dir to block write");
803 let svc = make_app_service_with_log_dir(log_dir).await;
804 let metrics = make_metrics_with_transcript();
805 let result = make_finished_result(metrics);
806 let warning = svc.maybe_log_transcript(&result, "fail-session");
807 assert!(warning.is_some(), "expected Some warning on write failure");
808 let msg = warning.unwrap();
809 assert!(
810 msg.contains("transcript"),
811 "warning should mention 'transcript', got: {msg}"
812 );
813 }
814
815 #[tokio::test]
816 async fn maybe_log_transcript_returns_none_on_non_finished() {
817 let tmp = tempfile::tempdir().expect("test tempdir");
818 let svc = make_app_service_with_log_dir(tmp.path().to_path_buf()).await;
819 let result = FeedResult::Accepted { remaining: 1 };
820 let warning = svc.maybe_log_transcript(&result, "any-session");
821 assert!(warning.is_none(), "Accepted result should return None");
822 }
823
824 #[test]
827 fn splice_transcript_warning_injects_field_when_some() {
828 let json = r#"{"status":"finished","result":{}}"#;
829 let out = splice_transcript_warning(json, Some("write failed".to_string()));
830 let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
831 assert_eq!(
832 v["transcript_warning"].as_str(),
833 Some("write failed"),
834 "transcript_warning field should be present"
835 );
836 assert_eq!(v["status"].as_str(), Some("finished"));
838 }
839
840 #[test]
841 fn splice_transcript_warning_passthrough_when_none() {
842 let json = r#"{"status":"finished"}"#;
843 let out = splice_transcript_warning(json, None);
844 let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
845 assert!(
846 v.get("transcript_warning").is_none(),
847 "transcript_warning must be absent when warning is None"
848 );
849 }
850
851 use crate::pool::PoolSessionEntry;
854
855 #[tokio::test]
861 async fn continue_single_in_mcp_path_on_registry_miss() {
862 let tmp = tempfile::tempdir().expect("test tempdir");
863 let svc = make_app_service_with_log_dir(tmp.path().to_path_buf()).await;
864
865 let result = svc
869 .continue_single(
870 "nonexistent-session-id",
871 "some response".to_string(),
872 None,
873 None,
874 )
875 .await;
876 assert!(
877 result.is_err(),
878 "unknown session must return Err on in-MCP path"
879 );
880 let msg = result.unwrap_err();
881 assert!(
882 msg.contains("not found") || msg.contains("Continue failed"),
883 "error must indicate session not found, got: {msg}"
884 );
885 }
886
887 #[tokio::test]
892 async fn app_service_new_initialises_empty_pool_registry() {
893 let tmp = tempfile::tempdir().expect("test tempdir");
894 let svc = make_app_service_with_log_dir(tmp.path().to_path_buf()).await;
895
896 let reg = svc.pool_registry.read().await;
897 assert!(
898 reg.sessions.is_empty(),
899 "pool registry must be empty on first-run (no registry.json)"
900 );
901 }
902
903 #[tokio::test]
908 async fn app_service_pool_paths_correctly_derived() {
909 let tmp = tempfile::tempdir().expect("test tempdir");
910 let svc = make_app_service_with_log_dir(tmp.path().to_path_buf()).await;
911
912 assert!(
913 svc.pool_dir.ends_with("pool"),
914 "pool_dir must end in 'pool', got: {}",
915 svc.pool_dir.display()
916 );
917 assert!(
918 svc.pool_reg_path.ends_with("pool/registry.json"),
919 "pool_reg_path must end in 'pool/registry.json', got: {}",
920 svc.pool_reg_path.display()
921 );
922 assert!(
923 svc.pool_lock_path.ends_with("pool/registry.lock"),
924 "pool_lock_path must end in 'pool/registry.lock', got: {}",
925 svc.pool_lock_path.display()
926 );
927 }
928
929 #[tokio::test]
935 async fn continue_single_propagates_corrupted_registry_error() {
936 let tmp = tempfile::tempdir().expect("test tempdir");
937 let svc = make_app_service_with_log_dir(tmp.path().to_path_buf()).await;
938
939 let pool_dir = svc.pool_dir.clone();
941 std::fs::create_dir_all(&pool_dir).expect("create pool dir");
942 std::fs::write(pool_dir.join("registry.json"), b"{ not valid json !!!")
943 .expect("write corrupt registry");
944
945 let result = svc
949 .continue_single("any-session-id", "response".to_string(), None, None)
950 .await;
951 assert!(
952 result.is_err(),
953 "corrupted registry must cause Err, not silent empty fallback"
954 );
955 let msg = result.unwrap_err();
956 assert!(
957 msg.contains("corrupted") || msg.contains("parse") || msg.contains("Continue failed"),
958 "error must mention registry problem, got: {msg}"
959 );
960 }
961
962 use super::super::eval_store::splice_response_string;
965
966 #[test]
972 fn splice_response_string_injects_cache_reload_warning() {
973 let json = r#"{"status":"finished","result":{"ok":true}}"#;
974 let msg = "failed to reload pool registry: No such file or directory";
975 let out = splice_response_string(json, "pool_cache_reload_warning", msg);
976 let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
977 assert_eq!(
978 v["pool_cache_reload_warning"].as_str(),
979 Some(msg),
980 "pool_cache_reload_warning must be present in response"
981 );
982 assert_eq!(v["status"].as_str(), Some("finished"));
984 }
985
986 #[test]
991 fn splice_response_string_passthrough_on_non_object_json() {
992 let non_object = r#""just a string""#;
993 let out = splice_response_string(non_object, "pool_cache_reload_warning", "err");
994 assert_eq!(out, non_object);
996 }
997
998 #[test]
1004 fn splice_response_string_not_called_when_none() {
1005 let json = r#"{"status":"finished"}"#;
1006 let v: serde_json::Value = serde_json::from_str(json).expect("valid JSON");
1008 assert!(
1009 v.get("pool_cache_reload_warning").is_none(),
1010 "pool_cache_reload_warning must be absent when no cache-reload error occurred"
1011 );
1012 }
1013
1014 #[tokio::test]
1020 async fn continue_single_routes_to_pool_on_registry_hit() {
1021 let tmp = tempfile::tempdir().expect("test tempdir");
1022 let svc = make_app_service_with_log_dir(tmp.path().to_path_buf()).await;
1023
1024 let fake_sock = tmp.path().join("nonexistent.sock");
1027 let entry = PoolSessionEntry::new(
1028 "test-pool-session",
1029 std::process::id(), fake_sock.clone(),
1031 env!("CARGO_PKG_VERSION"),
1032 );
1033 {
1034 let mut reg = svc.pool_registry.write().await;
1035 reg.add(entry);
1036 }
1037
1038 let result = svc
1042 .continue_single("test-pool-session", "response".to_string(), None, None)
1043 .await;
1044 assert!(
1045 result.is_err(),
1046 "pool path must fail with connect error (no real worker)"
1047 );
1048 let msg = result.unwrap_err();
1049 assert!(
1052 !msg.contains("session not found") || msg.contains("Continue failed"),
1053 "error must be from pool path (UDS connect), got: {msg}"
1054 );
1055 }
1056
1057 use super::resolve_env;
1060
1061 #[test]
1066 fn resolve_env_inject_keys_readable() {
1067 let ctx = serde_json::json!({
1068 "env": {
1069 "inject": { "FOO": "bar", "BAZ": "qux" }
1070 }
1071 });
1072 let map = resolve_env(&ctx, None, None).expect("resolve_env should succeed");
1073 assert_eq!(map.get("FOO").map(String::as_str), Some("bar"));
1074 assert_eq!(map.get("BAZ").map(String::as_str), Some("qux"));
1075 }
1076
1077 #[test]
1082 fn resolve_env_empty_ctx_produces_empty_map() {
1083 let ctx = serde_json::Value::Null;
1084 let map = resolve_env(&ctx, None, None).expect("resolve_env with null ctx should succeed");
1085 assert!(map.is_empty(), "empty ctx must produce an empty env map");
1086 }
1087
1088 #[test]
1092 fn resolve_env_inject_overwrites_dotenv() {
1093 let tmp = tempfile::tempdir().expect("test tempdir");
1094 let env_file = tmp.path().join(".env");
1095 std::fs::write(&env_file, b"PRIORITY=from_dotenv\n").expect("write .env");
1097
1098 let ctx = serde_json::json!({
1099 "env": {
1100 "dotenv": env_file.to_str().expect("valid path"),
1101 "inject": { "PRIORITY": "from_inject" }
1102 }
1103 });
1104 let map = resolve_env(&ctx, None, None).expect("resolve_env should succeed");
1105 assert_eq!(
1107 map.get("PRIORITY").map(String::as_str),
1108 Some("from_inject"),
1109 "inject must shadow dotenv for the same key"
1110 );
1111 }
1112
1113 #[test]
1117 fn resolve_env_dotenv_absolute_path_loaded() {
1118 let tmp = tempfile::tempdir().expect("test tempdir");
1119 let env_file = tmp.path().join(".env");
1120 std::fs::write(&env_file, b"DOTENV_KEY=dotenv_val\n").expect("write .env");
1121
1122 let ctx = serde_json::json!({
1123 "env": {
1124 "dotenv": env_file.to_str().expect("valid path")
1125 }
1126 });
1127 let map = resolve_env(&ctx, None, None).expect("resolve_env should succeed");
1128 assert_eq!(
1129 map.get("DOTENV_KEY").map(String::as_str),
1130 Some("dotenv_val"),
1131 "key from dotenv file must be accessible"
1132 );
1133 }
1134
1135 #[test]
1139 fn resolve_env_allowlist_filters_inject_keys() {
1140 let ctx = serde_json::json!({
1141 "env": {
1142 "inject": { "ALLOWED": "yes", "BLOCKED": "no" }
1143 }
1144 });
1145 let allow = vec!["ALLOWED".to_string()];
1146 let map =
1147 resolve_env(&ctx, None, Some(allow.as_slice())).expect("resolve_env should succeed");
1148 assert_eq!(map.get("ALLOWED").map(String::as_str), Some("yes"));
1149 assert!(
1150 map.get("BLOCKED").is_none(),
1151 "BLOCKED key must be excluded by allowlist"
1152 );
1153 }
1154
1155 #[test]
1159 fn resolve_env_allow_os_false_excludes_os_vars() {
1160 let ctx = serde_json::json!({ "env": { "allow_os": false } });
1162 let map = resolve_env(&ctx, None, None).expect("resolve_env should succeed");
1163 assert!(
1165 map.get("PATH").is_none(),
1166 "OS env must not leak when allow_os is false"
1167 );
1168 }
1169
1170 #[test]
1174 fn resolve_env_relative_dotenv_without_project_root_errors() {
1175 let ctx = serde_json::json!({
1176 "env": { "dotenv": ".env" }
1177 });
1178 let result = resolve_env(&ctx, None, None);
1179 assert!(
1180 result.is_err(),
1181 "relative dotenv path without project_root must return Err"
1182 );
1183 let msg = result.unwrap_err();
1184 assert!(
1185 msg.contains("project_root"),
1186 "error must mention project_root, got: {msg}"
1187 );
1188 }
1189
1190 #[test]
1194 fn resolve_env_relative_dotenv_with_project_root_resolved() {
1195 let tmp = tempfile::tempdir().expect("test tempdir");
1196 std::fs::write(tmp.path().join(".env"), b"REL_KEY=rel_val\n").expect("write .env");
1197
1198 let ctx = serde_json::json!({ "env": { "dotenv": ".env" } });
1199 let map = resolve_env(&ctx, Some(tmp.path()), None).expect("resolve_env should succeed");
1200 assert_eq!(
1201 map.get("REL_KEY").map(String::as_str),
1202 Some("rel_val"),
1203 "relative dotenv path must be resolved against project_root"
1204 );
1205 }
1206
1207 #[test]
1209 fn resolve_env_none_allowlist_keeps_all_inject_keys() {
1210 let ctx = serde_json::json!({
1211 "env": { "inject": { "A": "1", "B": "2" } }
1212 });
1213 let map = resolve_env(&ctx, None, None).expect("resolve_env should succeed");
1214 assert_eq!(
1215 map.len(),
1216 2,
1217 "all inject keys must be retained when allowlist is None"
1218 );
1219 }
1220
1221 #[test]
1225 fn resolve_env_inject_non_string_value_errors() {
1226 let ctx = serde_json::json!({
1227 "env": { "inject": { "BAD": 42 } }
1228 });
1229 let result = resolve_env(&ctx, None, None);
1230 assert!(result.is_err(), "non-string inject value must return Err");
1231 let msg = result.unwrap_err();
1232 assert!(
1233 msg.contains("BAD"),
1234 "error must mention the offending key, got: {msg}"
1235 );
1236 }
1237
1238 #[test]
1240 fn resolve_env_inject_not_an_object_errors() {
1241 let ctx = serde_json::json!({
1242 "env": { "inject": ["not", "an", "object"] }
1243 });
1244 let result = resolve_env(&ctx, None, None);
1245 assert!(result.is_err(), "non-object inject value must return Err");
1246 }
1247
1248 #[test]
1252 fn resolve_env_missing_dotenv_file_errors() {
1253 let ctx = serde_json::json!({
1254 "env": { "dotenv": "/nonexistent/path/to/.env" }
1255 });
1256 let result = resolve_env(&ctx, None, None);
1257 assert!(
1258 result.is_err(),
1259 "missing dotenv file must return Err, not empty map"
1260 );
1261 let msg = result.unwrap_err();
1262 assert!(
1263 msg.contains("dotenv"),
1264 "error must mention dotenv, got: {msg}"
1265 );
1266 }
1267
1268 use algocline_core::pkg::{PkgType, TypeSource};
1271
1272 fn make_temp_pkg(parent: &std::path::Path, pkg_name: &str, init_lua: &str) -> PathBuf {
1276 let pkg_dir = parent.join(pkg_name);
1277 std::fs::create_dir_all(&pkg_dir).expect("create pkg dir");
1278 std::fs::write(pkg_dir.join("init.lua"), init_lua).expect("write init.lua");
1279 parent.to_path_buf()
1280 }
1281
1282 async fn make_svc_with_pkg_root(pkg_root: PathBuf) -> AppService {
1285 let executor = Arc::new(
1286 algocline_engine::Executor::new(vec![pkg_root])
1287 .await
1288 .expect("executor"),
1289 );
1290 let log_config = super::super::config::AppConfig::default();
1293 AppService::new(executor, log_config, vec![])
1294 }
1295
1296 #[tokio::test]
1301 async fn resolve_pkg_type_lua_returns_auto_runnable() {
1302 let tmp = tempfile::tempdir().expect("test tempdir");
1303 let pkg_root = make_temp_pkg(
1304 tmp.path(),
1305 "auto_runnable",
1306 r#"local M = {}
1307M.meta = { name = "auto_runnable" }
1308M.run = function(ctx) return "ok" end
1309return M
1310"#,
1311 );
1312 let svc = make_svc_with_pkg_root(pkg_root).await;
1313 let result = svc
1314 .resolve_pkg_type_lua("auto_runnable", &[])
1315 .await
1316 .expect("eval must succeed");
1317 assert_eq!(
1318 result,
1319 Some((PkgType::Runnable, TypeSource::AutoDetectedRunnable)),
1320 "M.run present + no meta.type must produce (Runnable, AutoDetectedRunnable)"
1321 );
1322 }
1323
1324 #[tokio::test]
1332 async fn resolve_pkg_type_lua_returns_auto_library() {
1333 let tmp = tempfile::tempdir().expect("test tempdir");
1334 let pkg_root = make_temp_pkg(
1335 tmp.path(),
1336 "auto_library",
1337 r#"local M = {}
1338M.meta = { name = "auto_library" }
1339-- no M.run defined
1340return M
1341"#,
1342 );
1343 let svc = make_svc_with_pkg_root(pkg_root).await;
1344 let result = svc
1345 .resolve_pkg_type_lua("auto_library", &[])
1346 .await
1347 .expect("eval must succeed");
1348 assert_eq!(
1349 result,
1350 Some((PkgType::Library, TypeSource::AutoDetectedLibrary)),
1351 "no M.run + no meta.type must produce (Library, AutoDetectedLibrary)"
1352 );
1353 }
1354}