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 scenarios_dir = self.log_config.app_dir().scenarios_dir();
696 let session = self
697 .executor
698 .start_session_with_env(
699 env_map,
700 code,
701 ctx,
702 extra_lib_paths,
703 variant_pkgs,
704 Arc::clone(&self.state_store),
705 Arc::clone(&self.card_store),
706 scenarios_dir,
707 )
708 .await?;
709 let (session_id, result) = self
710 .registry
711 .start_execution(session)
712 .await
713 .map_err(|e| format!("Execution failed: {e}"))?;
714 if let Some(s) = strategy {
715 if let Ok(mut map) = self.session_strategies.lock() {
716 map.insert(session_id.clone(), s.to_string());
717 }
718 }
719 let transcript_warning = self.maybe_log_transcript(&result, &session_id);
720 let json = result.to_json(&session_id).to_string();
721 Ok(splice_transcript_warning(&json, transcript_warning))
722 }
723}
724
725#[cfg(test)]
726mod tests {
727 use std::path::PathBuf;
728 use std::sync::Arc;
729
730 use algocline_core::{
731 AppDir, ExecutionMetrics, ExecutionObserver, LlmQuery, QueryId, TerminalState,
732 };
733 use algocline_engine::{ExecutionResult, FeedResult};
734
735 use super::super::config::{AppConfig, LogDirSource};
736 use super::{splice_transcript_warning, AppService};
737
738 fn make_metrics_with_transcript() -> ExecutionMetrics {
739 let metrics = ExecutionMetrics::new();
740 let observer = metrics.create_observer();
741 observer.on_paused(&[LlmQuery {
742 id: QueryId::single(),
743 prompt: "test prompt".into(),
744 system: None,
745 max_tokens: 100,
746 grounded: false,
747 underspecified: false,
748 }]);
749 metrics
750 }
751
752 fn make_finished_result(metrics: ExecutionMetrics) -> FeedResult {
753 FeedResult::Finished(ExecutionResult {
754 state: TerminalState::Completed {
755 result: serde_json::json!({"ok": true}),
756 },
757 metrics,
758 })
759 }
760
761 async fn make_app_service_with_log_dir(log_dir: PathBuf) -> AppService {
763 let executor = Arc::new(
764 algocline_engine::Executor::new(vec![])
765 .await
766 .expect("executor"),
767 );
768 let tmp_app = tempfile::tempdir().expect("test tempdir");
769 let log_config = AppConfig {
770 log_dir: Some(log_dir),
771 log_dir_source: LogDirSource::EnvVar,
772 log_enabled: true,
773 prompt_preview_chars: 200,
774 app_dir: Arc::new(AppDir::new(tmp_app.path().to_path_buf())),
775 };
776 std::mem::forget(tmp_app);
777 AppService::new(executor, log_config, vec![])
778 }
779
780 #[tokio::test]
783 async fn maybe_log_transcript_returns_some_on_write_failure() {
784 let tmp = tempfile::tempdir().expect("test tempdir");
785 let log_dir = tmp.path().to_path_buf();
786 std::fs::create_dir_all(log_dir.join("fail-session.json"))
788 .expect("pre-create dir to block write");
789 let svc = make_app_service_with_log_dir(log_dir).await;
790 let metrics = make_metrics_with_transcript();
791 let result = make_finished_result(metrics);
792 let warning = svc.maybe_log_transcript(&result, "fail-session");
793 assert!(warning.is_some(), "expected Some warning on write failure");
794 let msg = warning.unwrap();
795 assert!(
796 msg.contains("transcript"),
797 "warning should mention 'transcript', got: {msg}"
798 );
799 }
800
801 #[tokio::test]
802 async fn maybe_log_transcript_returns_none_on_non_finished() {
803 let tmp = tempfile::tempdir().expect("test tempdir");
804 let svc = make_app_service_with_log_dir(tmp.path().to_path_buf()).await;
805 let result = FeedResult::Accepted { remaining: 1 };
806 let warning = svc.maybe_log_transcript(&result, "any-session");
807 assert!(warning.is_none(), "Accepted result should return None");
808 }
809
810 #[test]
813 fn splice_transcript_warning_injects_field_when_some() {
814 let json = r#"{"status":"finished","result":{}}"#;
815 let out = splice_transcript_warning(json, Some("write failed".to_string()));
816 let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
817 assert_eq!(
818 v["transcript_warning"].as_str(),
819 Some("write failed"),
820 "transcript_warning field should be present"
821 );
822 assert_eq!(v["status"].as_str(), Some("finished"));
824 }
825
826 #[test]
827 fn splice_transcript_warning_passthrough_when_none() {
828 let json = r#"{"status":"finished"}"#;
829 let out = splice_transcript_warning(json, None);
830 let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
831 assert!(
832 v.get("transcript_warning").is_none(),
833 "transcript_warning must be absent when warning is None"
834 );
835 }
836
837 use crate::pool::PoolSessionEntry;
840
841 #[tokio::test]
847 async fn continue_single_in_mcp_path_on_registry_miss() {
848 let tmp = tempfile::tempdir().expect("test tempdir");
849 let svc = make_app_service_with_log_dir(tmp.path().to_path_buf()).await;
850
851 let result = svc
855 .continue_single(
856 "nonexistent-session-id",
857 "some response".to_string(),
858 None,
859 None,
860 )
861 .await;
862 assert!(
863 result.is_err(),
864 "unknown session must return Err on in-MCP path"
865 );
866 let msg = result.unwrap_err();
867 assert!(
868 msg.contains("not found") || msg.contains("Continue failed"),
869 "error must indicate session not found, got: {msg}"
870 );
871 }
872
873 #[tokio::test]
878 async fn app_service_new_initialises_empty_pool_registry() {
879 let tmp = tempfile::tempdir().expect("test tempdir");
880 let svc = make_app_service_with_log_dir(tmp.path().to_path_buf()).await;
881
882 let reg = svc.pool_registry.read().await;
883 assert!(
884 reg.sessions.is_empty(),
885 "pool registry must be empty on first-run (no registry.json)"
886 );
887 }
888
889 #[tokio::test]
894 async fn app_service_pool_paths_correctly_derived() {
895 let tmp = tempfile::tempdir().expect("test tempdir");
896 let svc = make_app_service_with_log_dir(tmp.path().to_path_buf()).await;
897
898 assert!(
899 svc.pool_dir.ends_with("pool"),
900 "pool_dir must end in 'pool', got: {}",
901 svc.pool_dir.display()
902 );
903 assert!(
904 svc.pool_reg_path.ends_with("pool/registry.json"),
905 "pool_reg_path must end in 'pool/registry.json', got: {}",
906 svc.pool_reg_path.display()
907 );
908 assert!(
909 svc.pool_lock_path.ends_with("pool/registry.lock"),
910 "pool_lock_path must end in 'pool/registry.lock', got: {}",
911 svc.pool_lock_path.display()
912 );
913 }
914
915 #[tokio::test]
921 async fn continue_single_propagates_corrupted_registry_error() {
922 let tmp = tempfile::tempdir().expect("test tempdir");
923 let svc = make_app_service_with_log_dir(tmp.path().to_path_buf()).await;
924
925 let pool_dir = svc.pool_dir.clone();
927 std::fs::create_dir_all(&pool_dir).expect("create pool dir");
928 std::fs::write(pool_dir.join("registry.json"), b"{ not valid json !!!")
929 .expect("write corrupt registry");
930
931 let result = svc
935 .continue_single("any-session-id", "response".to_string(), None, None)
936 .await;
937 assert!(
938 result.is_err(),
939 "corrupted registry must cause Err, not silent empty fallback"
940 );
941 let msg = result.unwrap_err();
942 assert!(
943 msg.contains("corrupted") || msg.contains("parse") || msg.contains("Continue failed"),
944 "error must mention registry problem, got: {msg}"
945 );
946 }
947
948 use super::super::eval_store::splice_response_string;
951
952 #[test]
958 fn splice_response_string_injects_cache_reload_warning() {
959 let json = r#"{"status":"finished","result":{"ok":true}}"#;
960 let msg = "failed to reload pool registry: No such file or directory";
961 let out = splice_response_string(json, "pool_cache_reload_warning", msg);
962 let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
963 assert_eq!(
964 v["pool_cache_reload_warning"].as_str(),
965 Some(msg),
966 "pool_cache_reload_warning must be present in response"
967 );
968 assert_eq!(v["status"].as_str(), Some("finished"));
970 }
971
972 #[test]
977 fn splice_response_string_passthrough_on_non_object_json() {
978 let non_object = r#""just a string""#;
979 let out = splice_response_string(non_object, "pool_cache_reload_warning", "err");
980 assert_eq!(out, non_object);
982 }
983
984 #[test]
990 fn splice_response_string_not_called_when_none() {
991 let json = r#"{"status":"finished"}"#;
992 let v: serde_json::Value = serde_json::from_str(json).expect("valid JSON");
994 assert!(
995 v.get("pool_cache_reload_warning").is_none(),
996 "pool_cache_reload_warning must be absent when no cache-reload error occurred"
997 );
998 }
999
1000 #[tokio::test]
1006 async fn continue_single_routes_to_pool_on_registry_hit() {
1007 let tmp = tempfile::tempdir().expect("test tempdir");
1008 let svc = make_app_service_with_log_dir(tmp.path().to_path_buf()).await;
1009
1010 let fake_sock = tmp.path().join("nonexistent.sock");
1013 let entry = PoolSessionEntry::new(
1014 "test-pool-session",
1015 std::process::id(), fake_sock.clone(),
1017 env!("CARGO_PKG_VERSION"),
1018 );
1019 {
1020 let mut reg = svc.pool_registry.write().await;
1021 reg.add(entry);
1022 }
1023
1024 let result = svc
1028 .continue_single("test-pool-session", "response".to_string(), None, None)
1029 .await;
1030 assert!(
1031 result.is_err(),
1032 "pool path must fail with connect error (no real worker)"
1033 );
1034 let msg = result.unwrap_err();
1035 assert!(
1038 !msg.contains("session not found") || msg.contains("Continue failed"),
1039 "error must be from pool path (UDS connect), got: {msg}"
1040 );
1041 }
1042
1043 use super::resolve_env;
1046
1047 #[test]
1052 fn resolve_env_inject_keys_readable() {
1053 let ctx = serde_json::json!({
1054 "env": {
1055 "inject": { "FOO": "bar", "BAZ": "qux" }
1056 }
1057 });
1058 let map = resolve_env(&ctx, None, None).expect("resolve_env should succeed");
1059 assert_eq!(map.get("FOO").map(String::as_str), Some("bar"));
1060 assert_eq!(map.get("BAZ").map(String::as_str), Some("qux"));
1061 }
1062
1063 #[test]
1068 fn resolve_env_empty_ctx_produces_empty_map() {
1069 let ctx = serde_json::Value::Null;
1070 let map = resolve_env(&ctx, None, None).expect("resolve_env with null ctx should succeed");
1071 assert!(map.is_empty(), "empty ctx must produce an empty env map");
1072 }
1073
1074 #[test]
1078 fn resolve_env_inject_overwrites_dotenv() {
1079 let tmp = tempfile::tempdir().expect("test tempdir");
1080 let env_file = tmp.path().join(".env");
1081 std::fs::write(&env_file, b"PRIORITY=from_dotenv\n").expect("write .env");
1083
1084 let ctx = serde_json::json!({
1085 "env": {
1086 "dotenv": env_file.to_str().expect("valid path"),
1087 "inject": { "PRIORITY": "from_inject" }
1088 }
1089 });
1090 let map = resolve_env(&ctx, None, None).expect("resolve_env should succeed");
1091 assert_eq!(
1093 map.get("PRIORITY").map(String::as_str),
1094 Some("from_inject"),
1095 "inject must shadow dotenv for the same key"
1096 );
1097 }
1098
1099 #[test]
1103 fn resolve_env_dotenv_absolute_path_loaded() {
1104 let tmp = tempfile::tempdir().expect("test tempdir");
1105 let env_file = tmp.path().join(".env");
1106 std::fs::write(&env_file, b"DOTENV_KEY=dotenv_val\n").expect("write .env");
1107
1108 let ctx = serde_json::json!({
1109 "env": {
1110 "dotenv": env_file.to_str().expect("valid path")
1111 }
1112 });
1113 let map = resolve_env(&ctx, None, None).expect("resolve_env should succeed");
1114 assert_eq!(
1115 map.get("DOTENV_KEY").map(String::as_str),
1116 Some("dotenv_val"),
1117 "key from dotenv file must be accessible"
1118 );
1119 }
1120
1121 #[test]
1125 fn resolve_env_allowlist_filters_inject_keys() {
1126 let ctx = serde_json::json!({
1127 "env": {
1128 "inject": { "ALLOWED": "yes", "BLOCKED": "no" }
1129 }
1130 });
1131 let allow = vec!["ALLOWED".to_string()];
1132 let map =
1133 resolve_env(&ctx, None, Some(allow.as_slice())).expect("resolve_env should succeed");
1134 assert_eq!(map.get("ALLOWED").map(String::as_str), Some("yes"));
1135 assert!(
1136 map.get("BLOCKED").is_none(),
1137 "BLOCKED key must be excluded by allowlist"
1138 );
1139 }
1140
1141 #[test]
1145 fn resolve_env_allow_os_false_excludes_os_vars() {
1146 let ctx = serde_json::json!({ "env": { "allow_os": false } });
1148 let map = resolve_env(&ctx, None, None).expect("resolve_env should succeed");
1149 assert!(
1151 map.get("PATH").is_none(),
1152 "OS env must not leak when allow_os is false"
1153 );
1154 }
1155
1156 #[test]
1160 fn resolve_env_relative_dotenv_without_project_root_errors() {
1161 let ctx = serde_json::json!({
1162 "env": { "dotenv": ".env" }
1163 });
1164 let result = resolve_env(&ctx, None, None);
1165 assert!(
1166 result.is_err(),
1167 "relative dotenv path without project_root must return Err"
1168 );
1169 let msg = result.unwrap_err();
1170 assert!(
1171 msg.contains("project_root"),
1172 "error must mention project_root, got: {msg}"
1173 );
1174 }
1175
1176 #[test]
1180 fn resolve_env_relative_dotenv_with_project_root_resolved() {
1181 let tmp = tempfile::tempdir().expect("test tempdir");
1182 std::fs::write(tmp.path().join(".env"), b"REL_KEY=rel_val\n").expect("write .env");
1183
1184 let ctx = serde_json::json!({ "env": { "dotenv": ".env" } });
1185 let map = resolve_env(&ctx, Some(tmp.path()), None).expect("resolve_env should succeed");
1186 assert_eq!(
1187 map.get("REL_KEY").map(String::as_str),
1188 Some("rel_val"),
1189 "relative dotenv path must be resolved against project_root"
1190 );
1191 }
1192
1193 #[test]
1195 fn resolve_env_none_allowlist_keeps_all_inject_keys() {
1196 let ctx = serde_json::json!({
1197 "env": { "inject": { "A": "1", "B": "2" } }
1198 });
1199 let map = resolve_env(&ctx, None, None).expect("resolve_env should succeed");
1200 assert_eq!(
1201 map.len(),
1202 2,
1203 "all inject keys must be retained when allowlist is None"
1204 );
1205 }
1206
1207 #[test]
1211 fn resolve_env_inject_non_string_value_errors() {
1212 let ctx = serde_json::json!({
1213 "env": { "inject": { "BAD": 42 } }
1214 });
1215 let result = resolve_env(&ctx, None, None);
1216 assert!(result.is_err(), "non-string inject value must return Err");
1217 let msg = result.unwrap_err();
1218 assert!(
1219 msg.contains("BAD"),
1220 "error must mention the offending key, got: {msg}"
1221 );
1222 }
1223
1224 #[test]
1226 fn resolve_env_inject_not_an_object_errors() {
1227 let ctx = serde_json::json!({
1228 "env": { "inject": ["not", "an", "object"] }
1229 });
1230 let result = resolve_env(&ctx, None, None);
1231 assert!(result.is_err(), "non-object inject value must return Err");
1232 }
1233
1234 #[test]
1238 fn resolve_env_missing_dotenv_file_errors() {
1239 let ctx = serde_json::json!({
1240 "env": { "dotenv": "/nonexistent/path/to/.env" }
1241 });
1242 let result = resolve_env(&ctx, None, None);
1243 assert!(
1244 result.is_err(),
1245 "missing dotenv file must return Err, not empty map"
1246 );
1247 let msg = result.unwrap_err();
1248 assert!(
1249 msg.contains("dotenv"),
1250 "error must mention dotenv, got: {msg}"
1251 );
1252 }
1253
1254 use algocline_core::pkg::{PkgType, TypeSource};
1257
1258 fn make_temp_pkg(parent: &std::path::Path, pkg_name: &str, init_lua: &str) -> PathBuf {
1262 let pkg_dir = parent.join(pkg_name);
1263 std::fs::create_dir_all(&pkg_dir).expect("create pkg dir");
1264 std::fs::write(pkg_dir.join("init.lua"), init_lua).expect("write init.lua");
1265 parent.to_path_buf()
1266 }
1267
1268 async fn make_svc_with_pkg_root(pkg_root: PathBuf) -> AppService {
1271 let executor = Arc::new(
1272 algocline_engine::Executor::new(vec![pkg_root])
1273 .await
1274 .expect("executor"),
1275 );
1276 let log_config = super::super::config::AppConfig::default();
1279 AppService::new(executor, log_config, vec![])
1280 }
1281
1282 #[tokio::test]
1287 async fn resolve_pkg_type_lua_returns_auto_runnable() {
1288 let tmp = tempfile::tempdir().expect("test tempdir");
1289 let pkg_root = make_temp_pkg(
1290 tmp.path(),
1291 "auto_runnable",
1292 r#"local M = {}
1293M.meta = { name = "auto_runnable" }
1294M.run = function(ctx) return "ok" end
1295return M
1296"#,
1297 );
1298 let svc = make_svc_with_pkg_root(pkg_root).await;
1299 let result = svc
1300 .resolve_pkg_type_lua("auto_runnable", &[])
1301 .await
1302 .expect("eval must succeed");
1303 assert_eq!(
1304 result,
1305 Some((PkgType::Runnable, TypeSource::AutoDetectedRunnable)),
1306 "M.run present + no meta.type must produce (Runnable, AutoDetectedRunnable)"
1307 );
1308 }
1309
1310 #[tokio::test]
1318 async fn resolve_pkg_type_lua_returns_auto_library() {
1319 let tmp = tempfile::tempdir().expect("test tempdir");
1320 let pkg_root = make_temp_pkg(
1321 tmp.path(),
1322 "auto_library",
1323 r#"local M = {}
1324M.meta = { name = "auto_library" }
1325-- no M.run defined
1326return M
1327"#,
1328 );
1329 let svc = make_svc_with_pkg_root(pkg_root).await;
1330 let result = svc
1331 .resolve_pkg_type_lua("auto_library", &[])
1332 .await
1333 .expect("eval must succeed");
1334 assert_eq!(
1335 result,
1336 Some((PkgType::Library, TypeSource::AutoDetectedLibrary)),
1337 "no M.run + no meta.type must produce (Library, AutoDetectedLibrary)"
1338 );
1339 }
1340}