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();
321 if !is_package_installed(&app_dir, strategy) {
322 self.auto_install_bundled_packages().await?;
323 if !is_package_installed(&app_dir, strategy) {
324 return Err(format!(
325 "Package '{strategy}' not found after installing bundled collection. \
326 Use alc_pkg_install to install it manually."
327 ));
328 }
329 }
330
331 if let Some((PkgType::Library, _)) = self.resolve_pkg_type_lua(strategy).await? {
333 return Err(format!(
334 "Package '{strategy}' is a library package (type = \"library\"). \
335 Library packages provide reusable modules and do not have a run() entry point. \
336 Use alc_run with custom code to import this library."
337 ));
338 }
339
340 let code = make_require_code(strategy);
341
342 let opts = opts.map(normalize_stringified_json_object);
343 let mut ctx_map = match opts {
344 Some(serde_json::Value::Object(m)) => m,
345 _ => serde_json::Map::new(),
346 };
347 if let Some(task) = task {
348 ctx_map.insert("task".into(), serde_json::Value::String(task));
349 }
350 let ctx = serde_json::Value::Object(ctx_map);
351
352 let (extra, extra_warnings) = self.resolve_extra_lib_paths(project_root.as_deref());
353 let (variants, variant_warnings) = self.resolve_variant_pkgs(project_root.as_deref());
354 let mut warnings: Vec<String> = extra_warnings;
355 warnings.extend(variant_warnings);
356 let env_map = Arc::new(HashMap::new());
359 let json = self
360 .start_and_tick(env_map, code, ctx, Some(strategy), extra, variants)
361 .await?;
362 Ok(splice_response_warnings(
363 &json,
364 "lib_path_warnings",
365 &warnings,
366 ))
367 }
368
369 pub(crate) async fn resolve_pkg_type_lua(
388 &self,
389 name: &str,
390 ) -> Result<Option<(PkgType, TypeSource)>, String> {
391 let auto = super::resolve::LUA_TYPE_AUTODETECT;
392 let code = format!(
393 r#"package.loaded["{name}"] = nil; local pkg = require("{name}"); local meta = pkg.meta or {{}}; {auto}; return {{ type = meta.type, type_source = meta.type_source }}"#,
394 name = name,
395 auto = auto,
396 );
397 let val = self.executor.eval_simple(code).await?;
398 let result = val.as_object().and_then(|obj| {
399 let pkg_type =
400 obj.get("type")
401 .and_then(|v| v.as_str())
402 .and_then(|s| match s.parse::<PkgType>() {
403 Ok(t) => Some(t),
404 Err(e) => {
405 tracing::warn!(
406 package = name,
407 raw_type = s,
408 error = %e,
409 "unknown pkg type string; treating as legacy passthrough"
410 );
411 None
412 }
413 });
414 let type_source = obj
415 .get("type_source")
416 .and_then(|v| v.as_str())
417 .and_then(|s| s.parse::<TypeSource>().ok());
418 match (pkg_type, type_source) {
419 (Some(t), Some(src)) => Some((t, src)),
420 _ => None,
421 }
422 });
423 Ok(result)
424 }
425
426 pub async fn continue_batch(
433 &self,
434 session_id: &str,
435 responses: Vec<QueryResponse>,
436 ) -> Result<String, String> {
437 let pool_entry = {
439 let reg = self.pool_registry.read().await;
440 reg.find(session_id).cloned()
441 };
442
443 let pool_entry = if pool_entry.is_some() {
444 pool_entry
445 } else {
446 match crate::pool::PoolRegistry::load_or_default(&self.pool_reg_path) {
447 Ok(reg) => {
448 let entry = reg.find(session_id).cloned();
449 if entry.is_some() {
450 let mut guard = self.pool_registry.write().await;
451 *guard = reg;
452 }
453 entry
454 }
455 Err(e) => {
456 return Err(format!("Continue failed: {e}"));
457 }
458 }
459 };
460
461 if let Some(entry) = pool_entry {
462 let mut last_json = None;
464 for qr in responses {
465 let json =
466 continue_via_pool(&entry, session_id, qr.response, Some(qr.query_id), qr.usage)
467 .await
468 .map_err(|e| format!("Continue failed: {e}"))?;
469 last_json = Some(json);
470 }
471 return last_json.ok_or_else(|| "Empty responses array".to_string());
472 }
473
474 let mut last_result = None;
476 for qr in responses {
477 let qid = QueryId::parse(&qr.query_id);
478 let result = self
479 .registry
480 .feed_response(session_id, &qid, qr.response, qr.usage.as_ref())
481 .await
482 .map_err(|e| format!("Continue failed: {e}"))?;
483 last_result = Some(result);
484 }
485 let result = last_result.ok_or("Empty responses array")?;
486 let transcript_warning = self.maybe_log_transcript(&result, session_id);
487 let json = result.to_json(session_id).to_string();
488 let json = splice_transcript_warning(&json, transcript_warning);
489 let save_warning = self.maybe_save_eval(&result, session_id, &json);
490 Ok(splice_save_warning(&json, save_warning))
491 }
492
493 pub async fn continue_single(
514 &self,
515 session_id: &str,
516 response: String,
517 query_id: Option<&str>,
518 usage: Option<algocline_core::TokenUsage>,
519 ) -> Result<String, String> {
520 let pool_entry = {
523 let reg = self.pool_registry.read().await;
524 reg.find(session_id).cloned()
525 }; let pool_entry = if pool_entry.is_some() {
529 pool_entry
530 } else {
531 match crate::pool::PoolRegistry::load_or_default(&self.pool_reg_path) {
532 Ok(reg) => {
533 let entry = reg.find(session_id).cloned();
534 if entry.is_some() {
535 let mut guard = self.pool_registry.write().await;
537 *guard = reg;
538 }
539 entry
540 }
541 Err(e) => {
542 return Err(format!("Continue failed: {e}"));
544 }
545 }
546 };
547
548 if let Some(entry) = pool_entry {
549 let json = continue_via_pool(
551 &entry,
552 session_id,
553 response,
554 query_id.map(str::to_string),
555 usage,
556 )
557 .await
558 .map_err(|e| format!("Continue failed: {e}"))?;
559 return Ok(json);
560 }
561
562 let query_id = match query_id {
564 Some(qid) => QueryId::parse(qid),
565 None => self
566 .registry
567 .resolve_sole_pending_id(session_id)
568 .await
569 .map_err(|e| format!("Continue failed: {e}"))?,
570 };
571
572 let result = self
573 .registry
574 .feed_response(session_id, &query_id, response, usage.as_ref())
575 .await
576 .map_err(|e| format!("Continue failed: {e}"))?;
577
578 let transcript_warning = self.maybe_log_transcript(&result, session_id);
579 let json = result.to_json(session_id).to_string();
580 let json = splice_transcript_warning(&json, transcript_warning);
581 let save_warning = self.maybe_save_eval(&result, session_id, &json);
582 Ok(splice_save_warning(&json, save_warning))
583 }
584
585 pub(super) fn maybe_log_transcript(
588 &self,
589 result: &FeedResult,
590 session_id: &str,
591 ) -> Option<String> {
592 if let FeedResult::Finished(exec_result) = result {
593 let strategy = match self.session_strategies.lock() {
598 Ok(mut map) => map.remove(session_id),
599 Err(e) => {
600 tracing::warn!(
601 "session_strategies mutex poisoned for '{}': {}",
602 session_id,
603 e
604 );
605 return Some(format!(
608 "session_strategies mutex poisoned for '{session_id}': {e}"
609 ));
610 }
611 };
612 match write_transcript_log(
616 &self.log_config,
617 session_id,
618 &exec_result.metrics,
619 strategy.as_deref(),
620 ) {
621 Err(e) => Some(e.to_string()),
622 Ok(meta_warning) => meta_warning,
623 }
624 } else {
625 None
626 }
627 }
628
629 pub(super) fn maybe_save_eval(
635 &self,
636 result: &FeedResult,
637 session_id: &str,
638 result_json: &str,
639 ) -> Option<String> {
640 if !matches!(result, FeedResult::Finished(_)) {
641 return None;
642 }
643 let strategy = {
644 let mut map = self.eval_sessions.lock().unwrap_or_else(|e| e.into_inner());
645 map.remove(session_id)
646 };
647 strategy.and_then(|s| {
648 super::eval_store::save_eval_result(&self.log_config.app_dir(), &s, result_json).err()
649 })
650 }
651
652 pub(super) async fn start_and_tick(
670 &self,
671 env_map: Arc<HashMap<String, String>>,
672 code: String,
673 ctx: serde_json::Value,
674 strategy: Option<&str>,
675 extra_lib_paths: Vec<std::path::PathBuf>,
676 variant_pkgs: Vec<VariantPkg>,
677 ) -> Result<String, String> {
678 let scenarios_dir = self.log_config.app_dir().scenarios_dir();
679 let session = self
680 .executor
681 .start_session_with_env(
682 env_map,
683 code,
684 ctx,
685 extra_lib_paths,
686 variant_pkgs,
687 Arc::clone(&self.state_store),
688 Arc::clone(&self.card_store),
689 scenarios_dir,
690 )
691 .await?;
692 let (session_id, result) = self
693 .registry
694 .start_execution(session)
695 .await
696 .map_err(|e| format!("Execution failed: {e}"))?;
697 if let Some(s) = strategy {
698 if let Ok(mut map) = self.session_strategies.lock() {
699 map.insert(session_id.clone(), s.to_string());
700 }
701 }
702 let transcript_warning = self.maybe_log_transcript(&result, &session_id);
703 let json = result.to_json(&session_id).to_string();
704 Ok(splice_transcript_warning(&json, transcript_warning))
705 }
706}
707
708#[cfg(test)]
709mod tests {
710 use std::path::PathBuf;
711 use std::sync::Arc;
712
713 use algocline_core::{
714 AppDir, ExecutionMetrics, ExecutionObserver, LlmQuery, QueryId, TerminalState,
715 };
716 use algocline_engine::{ExecutionResult, FeedResult};
717
718 use super::super::config::{AppConfig, LogDirSource};
719 use super::{splice_transcript_warning, AppService};
720
721 fn make_metrics_with_transcript() -> ExecutionMetrics {
722 let metrics = ExecutionMetrics::new();
723 let observer = metrics.create_observer();
724 observer.on_paused(&[LlmQuery {
725 id: QueryId::single(),
726 prompt: "test prompt".into(),
727 system: None,
728 max_tokens: 100,
729 grounded: false,
730 underspecified: false,
731 }]);
732 metrics
733 }
734
735 fn make_finished_result(metrics: ExecutionMetrics) -> FeedResult {
736 FeedResult::Finished(ExecutionResult {
737 state: TerminalState::Completed {
738 result: serde_json::json!({"ok": true}),
739 },
740 metrics,
741 })
742 }
743
744 async fn make_app_service_with_log_dir(log_dir: PathBuf) -> AppService {
746 let executor = Arc::new(
747 algocline_engine::Executor::new(vec![])
748 .await
749 .expect("executor"),
750 );
751 let tmp_app = tempfile::tempdir().expect("test tempdir");
752 let log_config = AppConfig {
753 log_dir: Some(log_dir),
754 log_dir_source: LogDirSource::EnvVar,
755 log_enabled: true,
756 prompt_preview_chars: 200,
757 app_dir: Arc::new(AppDir::new(tmp_app.path().to_path_buf())),
758 };
759 std::mem::forget(tmp_app);
760 AppService::new(executor, log_config, vec![])
761 }
762
763 #[tokio::test]
766 async fn maybe_log_transcript_returns_some_on_write_failure() {
767 let tmp = tempfile::tempdir().expect("test tempdir");
768 let log_dir = tmp.path().to_path_buf();
769 std::fs::create_dir_all(log_dir.join("fail-session.json"))
771 .expect("pre-create dir to block write");
772 let svc = make_app_service_with_log_dir(log_dir).await;
773 let metrics = make_metrics_with_transcript();
774 let result = make_finished_result(metrics);
775 let warning = svc.maybe_log_transcript(&result, "fail-session");
776 assert!(warning.is_some(), "expected Some warning on write failure");
777 let msg = warning.unwrap();
778 assert!(
779 msg.contains("transcript"),
780 "warning should mention 'transcript', got: {msg}"
781 );
782 }
783
784 #[tokio::test]
785 async fn maybe_log_transcript_returns_none_on_non_finished() {
786 let tmp = tempfile::tempdir().expect("test tempdir");
787 let svc = make_app_service_with_log_dir(tmp.path().to_path_buf()).await;
788 let result = FeedResult::Accepted { remaining: 1 };
789 let warning = svc.maybe_log_transcript(&result, "any-session");
790 assert!(warning.is_none(), "Accepted result should return None");
791 }
792
793 #[test]
796 fn splice_transcript_warning_injects_field_when_some() {
797 let json = r#"{"status":"finished","result":{}}"#;
798 let out = splice_transcript_warning(json, Some("write failed".to_string()));
799 let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
800 assert_eq!(
801 v["transcript_warning"].as_str(),
802 Some("write failed"),
803 "transcript_warning field should be present"
804 );
805 assert_eq!(v["status"].as_str(), Some("finished"));
807 }
808
809 #[test]
810 fn splice_transcript_warning_passthrough_when_none() {
811 let json = r#"{"status":"finished"}"#;
812 let out = splice_transcript_warning(json, None);
813 let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
814 assert!(
815 v.get("transcript_warning").is_none(),
816 "transcript_warning must be absent when warning is None"
817 );
818 }
819
820 use crate::pool::PoolSessionEntry;
823
824 #[tokio::test]
830 async fn continue_single_in_mcp_path_on_registry_miss() {
831 let tmp = tempfile::tempdir().expect("test tempdir");
832 let svc = make_app_service_with_log_dir(tmp.path().to_path_buf()).await;
833
834 let result = svc
838 .continue_single(
839 "nonexistent-session-id",
840 "some response".to_string(),
841 None,
842 None,
843 )
844 .await;
845 assert!(
846 result.is_err(),
847 "unknown session must return Err on in-MCP path"
848 );
849 let msg = result.unwrap_err();
850 assert!(
851 msg.contains("not found") || msg.contains("Continue failed"),
852 "error must indicate session not found, got: {msg}"
853 );
854 }
855
856 #[tokio::test]
861 async fn app_service_new_initialises_empty_pool_registry() {
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 reg = svc.pool_registry.read().await;
866 assert!(
867 reg.sessions.is_empty(),
868 "pool registry must be empty on first-run (no registry.json)"
869 );
870 }
871
872 #[tokio::test]
877 async fn app_service_pool_paths_correctly_derived() {
878 let tmp = tempfile::tempdir().expect("test tempdir");
879 let svc = make_app_service_with_log_dir(tmp.path().to_path_buf()).await;
880
881 assert!(
882 svc.pool_dir.ends_with("pool"),
883 "pool_dir must end in 'pool', got: {}",
884 svc.pool_dir.display()
885 );
886 assert!(
887 svc.pool_reg_path.ends_with("pool/registry.json"),
888 "pool_reg_path must end in 'pool/registry.json', got: {}",
889 svc.pool_reg_path.display()
890 );
891 assert!(
892 svc.pool_lock_path.ends_with("pool/registry.lock"),
893 "pool_lock_path must end in 'pool/registry.lock', got: {}",
894 svc.pool_lock_path.display()
895 );
896 }
897
898 #[tokio::test]
904 async fn continue_single_propagates_corrupted_registry_error() {
905 let tmp = tempfile::tempdir().expect("test tempdir");
906 let svc = make_app_service_with_log_dir(tmp.path().to_path_buf()).await;
907
908 let pool_dir = svc.pool_dir.clone();
910 std::fs::create_dir_all(&pool_dir).expect("create pool dir");
911 std::fs::write(pool_dir.join("registry.json"), b"{ not valid json !!!")
912 .expect("write corrupt registry");
913
914 let result = svc
918 .continue_single("any-session-id", "response".to_string(), None, None)
919 .await;
920 assert!(
921 result.is_err(),
922 "corrupted registry must cause Err, not silent empty fallback"
923 );
924 let msg = result.unwrap_err();
925 assert!(
926 msg.contains("corrupted") || msg.contains("parse") || msg.contains("Continue failed"),
927 "error must mention registry problem, got: {msg}"
928 );
929 }
930
931 use super::super::eval_store::splice_response_string;
934
935 #[test]
941 fn splice_response_string_injects_cache_reload_warning() {
942 let json = r#"{"status":"finished","result":{"ok":true}}"#;
943 let msg = "failed to reload pool registry: No such file or directory";
944 let out = splice_response_string(json, "pool_cache_reload_warning", msg);
945 let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
946 assert_eq!(
947 v["pool_cache_reload_warning"].as_str(),
948 Some(msg),
949 "pool_cache_reload_warning must be present in response"
950 );
951 assert_eq!(v["status"].as_str(), Some("finished"));
953 }
954
955 #[test]
960 fn splice_response_string_passthrough_on_non_object_json() {
961 let non_object = r#""just a string""#;
962 let out = splice_response_string(non_object, "pool_cache_reload_warning", "err");
963 assert_eq!(out, non_object);
965 }
966
967 #[test]
973 fn splice_response_string_not_called_when_none() {
974 let json = r#"{"status":"finished"}"#;
975 let v: serde_json::Value = serde_json::from_str(json).expect("valid JSON");
977 assert!(
978 v.get("pool_cache_reload_warning").is_none(),
979 "pool_cache_reload_warning must be absent when no cache-reload error occurred"
980 );
981 }
982
983 #[tokio::test]
989 async fn continue_single_routes_to_pool_on_registry_hit() {
990 let tmp = tempfile::tempdir().expect("test tempdir");
991 let svc = make_app_service_with_log_dir(tmp.path().to_path_buf()).await;
992
993 let fake_sock = tmp.path().join("nonexistent.sock");
996 let entry = PoolSessionEntry::new(
997 "test-pool-session",
998 std::process::id(), fake_sock.clone(),
1000 env!("CARGO_PKG_VERSION"),
1001 );
1002 {
1003 let mut reg = svc.pool_registry.write().await;
1004 reg.add(entry);
1005 }
1006
1007 let result = svc
1011 .continue_single("test-pool-session", "response".to_string(), None, None)
1012 .await;
1013 assert!(
1014 result.is_err(),
1015 "pool path must fail with connect error (no real worker)"
1016 );
1017 let msg = result.unwrap_err();
1018 assert!(
1021 !msg.contains("session not found") || msg.contains("Continue failed"),
1022 "error must be from pool path (UDS connect), got: {msg}"
1023 );
1024 }
1025
1026 use super::resolve_env;
1029
1030 #[test]
1035 fn resolve_env_inject_keys_readable() {
1036 let ctx = serde_json::json!({
1037 "env": {
1038 "inject": { "FOO": "bar", "BAZ": "qux" }
1039 }
1040 });
1041 let map = resolve_env(&ctx, None, None).expect("resolve_env should succeed");
1042 assert_eq!(map.get("FOO").map(String::as_str), Some("bar"));
1043 assert_eq!(map.get("BAZ").map(String::as_str), Some("qux"));
1044 }
1045
1046 #[test]
1051 fn resolve_env_empty_ctx_produces_empty_map() {
1052 let ctx = serde_json::Value::Null;
1053 let map = resolve_env(&ctx, None, None).expect("resolve_env with null ctx should succeed");
1054 assert!(map.is_empty(), "empty ctx must produce an empty env map");
1055 }
1056
1057 #[test]
1061 fn resolve_env_inject_overwrites_dotenv() {
1062 let tmp = tempfile::tempdir().expect("test tempdir");
1063 let env_file = tmp.path().join(".env");
1064 std::fs::write(&env_file, b"PRIORITY=from_dotenv\n").expect("write .env");
1066
1067 let ctx = serde_json::json!({
1068 "env": {
1069 "dotenv": env_file.to_str().expect("valid path"),
1070 "inject": { "PRIORITY": "from_inject" }
1071 }
1072 });
1073 let map = resolve_env(&ctx, None, None).expect("resolve_env should succeed");
1074 assert_eq!(
1076 map.get("PRIORITY").map(String::as_str),
1077 Some("from_inject"),
1078 "inject must shadow dotenv for the same key"
1079 );
1080 }
1081
1082 #[test]
1086 fn resolve_env_dotenv_absolute_path_loaded() {
1087 let tmp = tempfile::tempdir().expect("test tempdir");
1088 let env_file = tmp.path().join(".env");
1089 std::fs::write(&env_file, b"DOTENV_KEY=dotenv_val\n").expect("write .env");
1090
1091 let ctx = serde_json::json!({
1092 "env": {
1093 "dotenv": env_file.to_str().expect("valid path")
1094 }
1095 });
1096 let map = resolve_env(&ctx, None, None).expect("resolve_env should succeed");
1097 assert_eq!(
1098 map.get("DOTENV_KEY").map(String::as_str),
1099 Some("dotenv_val"),
1100 "key from dotenv file must be accessible"
1101 );
1102 }
1103
1104 #[test]
1108 fn resolve_env_allowlist_filters_inject_keys() {
1109 let ctx = serde_json::json!({
1110 "env": {
1111 "inject": { "ALLOWED": "yes", "BLOCKED": "no" }
1112 }
1113 });
1114 let allow = vec!["ALLOWED".to_string()];
1115 let map =
1116 resolve_env(&ctx, None, Some(allow.as_slice())).expect("resolve_env should succeed");
1117 assert_eq!(map.get("ALLOWED").map(String::as_str), Some("yes"));
1118 assert!(
1119 map.get("BLOCKED").is_none(),
1120 "BLOCKED key must be excluded by allowlist"
1121 );
1122 }
1123
1124 #[test]
1128 fn resolve_env_allow_os_false_excludes_os_vars() {
1129 let ctx = serde_json::json!({ "env": { "allow_os": false } });
1131 let map = resolve_env(&ctx, None, None).expect("resolve_env should succeed");
1132 assert!(
1134 map.get("PATH").is_none(),
1135 "OS env must not leak when allow_os is false"
1136 );
1137 }
1138
1139 #[test]
1143 fn resolve_env_relative_dotenv_without_project_root_errors() {
1144 let ctx = serde_json::json!({
1145 "env": { "dotenv": ".env" }
1146 });
1147 let result = resolve_env(&ctx, None, None);
1148 assert!(
1149 result.is_err(),
1150 "relative dotenv path without project_root must return Err"
1151 );
1152 let msg = result.unwrap_err();
1153 assert!(
1154 msg.contains("project_root"),
1155 "error must mention project_root, got: {msg}"
1156 );
1157 }
1158
1159 #[test]
1163 fn resolve_env_relative_dotenv_with_project_root_resolved() {
1164 let tmp = tempfile::tempdir().expect("test tempdir");
1165 std::fs::write(tmp.path().join(".env"), b"REL_KEY=rel_val\n").expect("write .env");
1166
1167 let ctx = serde_json::json!({ "env": { "dotenv": ".env" } });
1168 let map = resolve_env(&ctx, Some(tmp.path()), None).expect("resolve_env should succeed");
1169 assert_eq!(
1170 map.get("REL_KEY").map(String::as_str),
1171 Some("rel_val"),
1172 "relative dotenv path must be resolved against project_root"
1173 );
1174 }
1175
1176 #[test]
1178 fn resolve_env_none_allowlist_keeps_all_inject_keys() {
1179 let ctx = serde_json::json!({
1180 "env": { "inject": { "A": "1", "B": "2" } }
1181 });
1182 let map = resolve_env(&ctx, None, None).expect("resolve_env should succeed");
1183 assert_eq!(
1184 map.len(),
1185 2,
1186 "all inject keys must be retained when allowlist is None"
1187 );
1188 }
1189
1190 #[test]
1194 fn resolve_env_inject_non_string_value_errors() {
1195 let ctx = serde_json::json!({
1196 "env": { "inject": { "BAD": 42 } }
1197 });
1198 let result = resolve_env(&ctx, None, None);
1199 assert!(result.is_err(), "non-string inject value must return Err");
1200 let msg = result.unwrap_err();
1201 assert!(
1202 msg.contains("BAD"),
1203 "error must mention the offending key, got: {msg}"
1204 );
1205 }
1206
1207 #[test]
1209 fn resolve_env_inject_not_an_object_errors() {
1210 let ctx = serde_json::json!({
1211 "env": { "inject": ["not", "an", "object"] }
1212 });
1213 let result = resolve_env(&ctx, None, None);
1214 assert!(result.is_err(), "non-object inject value must return Err");
1215 }
1216
1217 #[test]
1221 fn resolve_env_missing_dotenv_file_errors() {
1222 let ctx = serde_json::json!({
1223 "env": { "dotenv": "/nonexistent/path/to/.env" }
1224 });
1225 let result = resolve_env(&ctx, None, None);
1226 assert!(
1227 result.is_err(),
1228 "missing dotenv file must return Err, not empty map"
1229 );
1230 let msg = result.unwrap_err();
1231 assert!(
1232 msg.contains("dotenv"),
1233 "error must mention dotenv, got: {msg}"
1234 );
1235 }
1236
1237 use algocline_core::pkg::{PkgType, TypeSource};
1240
1241 fn make_temp_pkg(parent: &std::path::Path, pkg_name: &str, init_lua: &str) -> PathBuf {
1245 let pkg_dir = parent.join(pkg_name);
1246 std::fs::create_dir_all(&pkg_dir).expect("create pkg dir");
1247 std::fs::write(pkg_dir.join("init.lua"), init_lua).expect("write init.lua");
1248 parent.to_path_buf()
1249 }
1250
1251 async fn make_svc_with_pkg_root(pkg_root: PathBuf) -> AppService {
1254 let executor = Arc::new(
1255 algocline_engine::Executor::new(vec![pkg_root])
1256 .await
1257 .expect("executor"),
1258 );
1259 let log_config = super::super::config::AppConfig::default();
1262 AppService::new(executor, log_config, vec![])
1263 }
1264
1265 #[tokio::test]
1270 async fn resolve_pkg_type_lua_returns_auto_runnable() {
1271 let tmp = tempfile::tempdir().expect("test tempdir");
1272 let pkg_root = make_temp_pkg(
1273 tmp.path(),
1274 "auto_runnable",
1275 r#"local M = {}
1276M.meta = { name = "auto_runnable" }
1277M.run = function(ctx) return "ok" end
1278return M
1279"#,
1280 );
1281 let svc = make_svc_with_pkg_root(pkg_root).await;
1282 let result = svc
1283 .resolve_pkg_type_lua("auto_runnable")
1284 .await
1285 .expect("eval must succeed");
1286 assert_eq!(
1287 result,
1288 Some((PkgType::Runnable, TypeSource::AutoDetectedRunnable)),
1289 "M.run present + no meta.type must produce (Runnable, AutoDetectedRunnable)"
1290 );
1291 }
1292
1293 #[tokio::test]
1301 async fn resolve_pkg_type_lua_returns_auto_library() {
1302 let tmp = tempfile::tempdir().expect("test tempdir");
1303 let pkg_root = make_temp_pkg(
1304 tmp.path(),
1305 "auto_library",
1306 r#"local M = {}
1307M.meta = { name = "auto_library" }
1308-- no M.run defined
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_library")
1315 .await
1316 .expect("eval must succeed");
1317 assert_eq!(
1318 result,
1319 Some((PkgType::Library, TypeSource::AutoDetectedLibrary)),
1320 "no M.run + no meta.type must produce (Library, AutoDetectedLibrary)"
1321 );
1322 }
1323}