1use std::collections::HashMap;
2use std::sync::Arc;
3
4use algocline_core::QueryId;
5use algocline_engine::{FeedResult, VariantPkg};
6
7use super::alc_toml::load_alc_toml;
8use super::eval_store::{splice_response_string, splice_response_warnings};
9use super::resolve::{is_package_installed, make_require_code, resolve_code, QueryResponse};
10use super::transcript::write_transcript_log;
11use super::AppService;
12use crate::pool::dispatch::{continue_via_pool, run_via_pool};
13
14pub(crate) fn normalize_stringified_json_object(v: serde_json::Value) -> serde_json::Value {
25 match v {
26 serde_json::Value::String(ref s) => match serde_json::from_str::<serde_json::Value>(s) {
27 Ok(parsed @ serde_json::Value::Object(_)) => parsed,
28 Ok(parsed @ serde_json::Value::Array(_)) => parsed,
29 _ => v,
30 },
31 other => other,
32 }
33}
34
35fn splice_save_warning(result_json: &str, warning: Option<String>) -> String {
39 match warning {
40 Some(msg) => splice_response_string(result_json, "save_warning", &msg),
41 None => result_json.to_string(),
42 }
43}
44
45fn splice_transcript_warning(result_json: &str, warning: Option<String>) -> String {
49 match warning {
50 Some(msg) => splice_response_string(result_json, "transcript_warning", &msg),
51 None => result_json.to_string(),
52 }
53}
54
55pub(super) fn resolve_env(
80 ctx: &serde_json::Value,
81 project_root: Option<&std::path::Path>,
82 alc_toml_allow: Option<&[String]>,
83) -> Result<Arc<HashMap<String, String>>, String> {
84 let env_obj = ctx.get("env").and_then(|v| v.as_object());
85
86 let inject: HashMap<String, String> = if let Some(obj) = env_obj {
88 if let Some(inject_val) = obj.get("inject") {
89 match inject_val.as_object() {
90 Some(m) => {
91 let mut map = HashMap::new();
92 for (k, v) in m {
93 match v.as_str() {
94 Some(s) => {
95 map.insert(k.clone(), s.to_string());
96 }
97 None => {
98 return Err(format!(
99 "ctx.env.inject: value for key '{k}' must be a string, got {v}"
100 ));
101 }
102 }
103 }
104 map
105 }
106 None => {
107 return Err(format!(
108 "ctx.env.inject must be an object, got {}",
109 inject_val
110 ));
111 }
112 }
113 } else {
114 HashMap::new()
115 }
116 } else {
117 HashMap::new()
118 };
119
120 let dotenv_path: Option<std::path::PathBuf> = if let Some(p) = env_obj
122 .and_then(|o| o.get("dotenv"))
123 .and_then(|v| v.as_str())
124 {
125 let path = std::path::Path::new(p);
126 if path.is_absolute() {
127 Some(path.to_path_buf())
128 } else {
129 match project_root {
130 Some(root) => Some(root.join(p)),
131 None => {
132 return Err(format!(
133 "ctx.env.dotenv: relative path '{p}' requires project_root to be set"
134 ));
135 }
136 }
137 }
138 } else {
139 None
140 };
141
142 let allow_os = env_obj
143 .and_then(|o| o.get("allow_os"))
144 .and_then(|v| v.as_bool())
145 .unwrap_or(false);
146
147 let mut merged: HashMap<String, String> = HashMap::new();
148
149 if allow_os {
151 for (k, v) in std::env::vars() {
152 merged.insert(k, v);
153 }
154 }
155
156 if let Some(ref full) = dotenv_path {
158 let iter = dotenvy::from_path_iter(full)
159 .map_err(|e| format!("ctx.env.dotenv: failed to open '{}': {e}", full.display()))?;
160 for item in iter {
161 let (k, v) = item
162 .map_err(|e| format!("ctx.env.dotenv: parse error in '{}': {e}", full.display()))?;
163 merged.insert(k, v);
164 }
165 }
166
167 for (k, v) in inject {
169 merged.insert(k, v);
170 }
171
172 if let Some(allow) = alc_toml_allow {
174 if !allow.is_empty() {
175 let allowset: std::collections::HashSet<&String> = allow.iter().collect();
176 merged.retain(|k, _| allowset.contains(k));
177 }
178 }
179
180 Ok(Arc::new(merged))
181}
182
183impl AppService {
184 pub async fn run(
204 &self,
205 code: Option<String>,
206 code_file: Option<String>,
207 ctx: Option<serde_json::Value>,
208 project_root: Option<String>,
209 host_mode: Option<bool>,
210 ) -> Result<String, String> {
211 let code = resolve_code(code, code_file)?;
212 let ctx = normalize_stringified_json_object(ctx.unwrap_or(serde_json::Value::Null));
213 let (extra, extra_warnings) = self.resolve_extra_lib_paths(project_root.as_deref());
214 let (variants, variant_warnings) = self.resolve_variant_pkgs(project_root.as_deref());
215 let mut warnings: Vec<String> = extra_warnings;
216 warnings.extend(variant_warnings);
217
218 if host_mode == Some(true) {
219 let (session_id, json, pool_save_error) = run_via_pool(
223 &self.pool_dir,
224 &self.pool_reg_path,
225 &self.pool_lock_path,
226 extra,
227 code,
228 ctx,
229 )
230 .await
231 .map_err(|e| e.to_string())?;
232
233 let cache_reload_warning: Option<String> =
243 match crate::pool::PoolRegistry::load_or_default(&self.pool_reg_path) {
244 Ok(reg) => {
245 let mut guard = self.pool_registry.write().await;
246 *guard = reg;
247 None
248 }
249 Err(e) => {
250 tracing::warn!(
251 error = %e,
252 "failed to reload pool registry after run; in-memory cache may be stale"
253 );
254 Some(e.to_string())
255 }
256 };
257
258 let json = splice_response_warnings(&json, "lib_path_warnings", &warnings);
259 let json = match pool_save_error {
260 Some(msg) => splice_response_string(&json, "pool_save_error", &msg),
261 None => json,
262 };
263 let json = match cache_reload_warning {
264 Some(msg) => splice_response_string(&json, "pool_cache_reload_warning", &msg),
265 None => json,
266 };
267 let _ = session_id; return Ok(json);
269 }
270
271 let alc_toml_allow_list: Vec<String> = if let Some(root) = project_root.as_deref() {
276 let root_path = std::path::Path::new(root);
277 match load_alc_toml(root_path) {
278 Ok(Some(t)) => t.env.map(|e| e.allow).unwrap_or_default(),
279 Ok(None) => Vec::new(),
280 Err(e) => return Err(format!("alc.toml load error: {e}")),
281 }
282 } else {
283 Vec::new()
284 };
285 let alc_toml_allow = if alc_toml_allow_list.is_empty() {
286 None
287 } else {
288 Some(alc_toml_allow_list.as_slice())
289 };
290
291 let project_root_path = project_root.as_deref().map(std::path::Path::new);
292 let env_map = resolve_env(&ctx, project_root_path, alc_toml_allow)?;
293
294 let json = self
295 .start_and_tick(env_map, code, ctx, None, extra, variants)
296 .await?;
297 Ok(splice_response_warnings(
298 &json,
299 "lib_path_warnings",
300 &warnings,
301 ))
302 }
303
304 pub async fn advice(
312 &self,
313 strategy: &str,
314 task: Option<String>,
315 opts: Option<serde_json::Value>,
316 project_root: Option<String>,
317 ) -> Result<String, String> {
318 let app_dir = self.log_config.app_dir();
320 if !is_package_installed(&app_dir, strategy) {
321 self.auto_install_bundled_packages().await?;
322 if !is_package_installed(&app_dir, strategy) {
323 return Err(format!(
324 "Package '{strategy}' not found after installing bundled collection. \
325 Use alc_pkg_install to install it manually."
326 ));
327 }
328 }
329
330 let code = make_require_code(strategy);
331
332 let opts = opts.map(normalize_stringified_json_object);
333 let mut ctx_map = match opts {
334 Some(serde_json::Value::Object(m)) => m,
335 _ => serde_json::Map::new(),
336 };
337 if let Some(task) = task {
338 ctx_map.insert("task".into(), serde_json::Value::String(task));
339 }
340 let ctx = serde_json::Value::Object(ctx_map);
341
342 let (extra, extra_warnings) = self.resolve_extra_lib_paths(project_root.as_deref());
343 let (variants, variant_warnings) = self.resolve_variant_pkgs(project_root.as_deref());
344 let mut warnings: Vec<String> = extra_warnings;
345 warnings.extend(variant_warnings);
346 let env_map = Arc::new(HashMap::new());
349 let json = self
350 .start_and_tick(env_map, code, ctx, Some(strategy), extra, variants)
351 .await?;
352 Ok(splice_response_warnings(
353 &json,
354 "lib_path_warnings",
355 &warnings,
356 ))
357 }
358
359 pub async fn continue_batch(
366 &self,
367 session_id: &str,
368 responses: Vec<QueryResponse>,
369 ) -> Result<String, String> {
370 let pool_entry = {
372 let reg = self.pool_registry.read().await;
373 reg.find(session_id).cloned()
374 };
375
376 let pool_entry = if pool_entry.is_some() {
377 pool_entry
378 } else {
379 match crate::pool::PoolRegistry::load_or_default(&self.pool_reg_path) {
380 Ok(reg) => {
381 let entry = reg.find(session_id).cloned();
382 if entry.is_some() {
383 let mut guard = self.pool_registry.write().await;
384 *guard = reg;
385 }
386 entry
387 }
388 Err(e) => {
389 return Err(format!("Continue failed: {e}"));
390 }
391 }
392 };
393
394 if let Some(entry) = pool_entry {
395 let mut last_json = None;
397 for qr in responses {
398 let json =
399 continue_via_pool(&entry, session_id, qr.response, Some(qr.query_id), qr.usage)
400 .await
401 .map_err(|e| format!("Continue failed: {e}"))?;
402 last_json = Some(json);
403 }
404 return last_json.ok_or_else(|| "Empty responses array".to_string());
405 }
406
407 let mut last_result = None;
409 for qr in responses {
410 let qid = QueryId::parse(&qr.query_id);
411 let result = self
412 .registry
413 .feed_response(session_id, &qid, qr.response, qr.usage.as_ref())
414 .await
415 .map_err(|e| format!("Continue failed: {e}"))?;
416 last_result = Some(result);
417 }
418 let result = last_result.ok_or("Empty responses array")?;
419 let transcript_warning = self.maybe_log_transcript(&result, session_id);
420 let json = result.to_json(session_id).to_string();
421 let json = splice_transcript_warning(&json, transcript_warning);
422 let save_warning = self.maybe_save_eval(&result, session_id, &json);
423 Ok(splice_save_warning(&json, save_warning))
424 }
425
426 pub async fn continue_single(
447 &self,
448 session_id: &str,
449 response: String,
450 query_id: Option<&str>,
451 usage: Option<algocline_core::TokenUsage>,
452 ) -> Result<String, String> {
453 let pool_entry = {
456 let reg = self.pool_registry.read().await;
457 reg.find(session_id).cloned()
458 }; let pool_entry = if pool_entry.is_some() {
462 pool_entry
463 } else {
464 match crate::pool::PoolRegistry::load_or_default(&self.pool_reg_path) {
465 Ok(reg) => {
466 let entry = reg.find(session_id).cloned();
467 if entry.is_some() {
468 let mut guard = self.pool_registry.write().await;
470 *guard = reg;
471 }
472 entry
473 }
474 Err(e) => {
475 return Err(format!("Continue failed: {e}"));
477 }
478 }
479 };
480
481 if let Some(entry) = pool_entry {
482 let json = continue_via_pool(
484 &entry,
485 session_id,
486 response,
487 query_id.map(str::to_string),
488 usage,
489 )
490 .await
491 .map_err(|e| format!("Continue failed: {e}"))?;
492 return Ok(json);
493 }
494
495 let query_id = match query_id {
497 Some(qid) => QueryId::parse(qid),
498 None => self
499 .registry
500 .resolve_sole_pending_id(session_id)
501 .await
502 .map_err(|e| format!("Continue failed: {e}"))?,
503 };
504
505 let result = self
506 .registry
507 .feed_response(session_id, &query_id, response, usage.as_ref())
508 .await
509 .map_err(|e| format!("Continue failed: {e}"))?;
510
511 let transcript_warning = self.maybe_log_transcript(&result, session_id);
512 let json = result.to_json(session_id).to_string();
513 let json = splice_transcript_warning(&json, transcript_warning);
514 let save_warning = self.maybe_save_eval(&result, session_id, &json);
515 Ok(splice_save_warning(&json, save_warning))
516 }
517
518 pub(super) fn maybe_log_transcript(
521 &self,
522 result: &FeedResult,
523 session_id: &str,
524 ) -> Option<String> {
525 if let FeedResult::Finished(exec_result) = result {
526 let strategy = match self.session_strategies.lock() {
531 Ok(mut map) => map.remove(session_id),
532 Err(e) => {
533 tracing::warn!(
534 "session_strategies mutex poisoned for '{}': {}",
535 session_id,
536 e
537 );
538 return Some(format!(
541 "session_strategies mutex poisoned for '{session_id}': {e}"
542 ));
543 }
544 };
545 match write_transcript_log(
549 &self.log_config,
550 session_id,
551 &exec_result.metrics,
552 strategy.as_deref(),
553 ) {
554 Err(e) => Some(e.to_string()),
555 Ok(meta_warning) => meta_warning,
556 }
557 } else {
558 None
559 }
560 }
561
562 pub(super) fn maybe_save_eval(
568 &self,
569 result: &FeedResult,
570 session_id: &str,
571 result_json: &str,
572 ) -> Option<String> {
573 if !matches!(result, FeedResult::Finished(_)) {
574 return None;
575 }
576 let strategy = {
577 let mut map = self.eval_sessions.lock().unwrap_or_else(|e| e.into_inner());
578 map.remove(session_id)
579 };
580 strategy.and_then(|s| {
581 super::eval_store::save_eval_result(&self.log_config.app_dir(), &s, result_json).err()
582 })
583 }
584
585 pub(super) async fn start_and_tick(
603 &self,
604 env_map: Arc<HashMap<String, String>>,
605 code: String,
606 ctx: serde_json::Value,
607 strategy: Option<&str>,
608 extra_lib_paths: Vec<std::path::PathBuf>,
609 variant_pkgs: Vec<VariantPkg>,
610 ) -> Result<String, String> {
611 let scenarios_dir = self.log_config.app_dir().scenarios_dir();
612 let session = self
613 .executor
614 .start_session_with_env(
615 env_map,
616 code,
617 ctx,
618 extra_lib_paths,
619 variant_pkgs,
620 Arc::clone(&self.state_store),
621 Arc::clone(&self.card_store),
622 scenarios_dir,
623 )
624 .await?;
625 let (session_id, result) = self
626 .registry
627 .start_execution(session)
628 .await
629 .map_err(|e| format!("Execution failed: {e}"))?;
630 if let Some(s) = strategy {
631 if let Ok(mut map) = self.session_strategies.lock() {
632 map.insert(session_id.clone(), s.to_string());
633 }
634 }
635 let transcript_warning = self.maybe_log_transcript(&result, &session_id);
636 let json = result.to_json(&session_id).to_string();
637 Ok(splice_transcript_warning(&json, transcript_warning))
638 }
639}
640
641#[cfg(test)]
642mod tests {
643 use std::path::PathBuf;
644 use std::sync::Arc;
645
646 use algocline_core::{
647 AppDir, ExecutionMetrics, ExecutionObserver, LlmQuery, QueryId, TerminalState,
648 };
649 use algocline_engine::{ExecutionResult, FeedResult};
650
651 use super::super::config::{AppConfig, LogDirSource};
652 use super::{splice_transcript_warning, AppService};
653
654 fn make_metrics_with_transcript() -> ExecutionMetrics {
655 let metrics = ExecutionMetrics::new();
656 let observer = metrics.create_observer();
657 observer.on_paused(&[LlmQuery {
658 id: QueryId::single(),
659 prompt: "test prompt".into(),
660 system: None,
661 max_tokens: 100,
662 grounded: false,
663 underspecified: false,
664 }]);
665 metrics
666 }
667
668 fn make_finished_result(metrics: ExecutionMetrics) -> FeedResult {
669 FeedResult::Finished(ExecutionResult {
670 state: TerminalState::Completed {
671 result: serde_json::json!({"ok": true}),
672 },
673 metrics,
674 })
675 }
676
677 async fn make_app_service_with_log_dir(log_dir: PathBuf) -> AppService {
679 let executor = Arc::new(
680 algocline_engine::Executor::new(vec![])
681 .await
682 .expect("executor"),
683 );
684 let tmp_app = tempfile::tempdir().expect("test tempdir");
685 let log_config = AppConfig {
686 log_dir: Some(log_dir),
687 log_dir_source: LogDirSource::EnvVar,
688 log_enabled: true,
689 prompt_preview_chars: 200,
690 app_dir: Arc::new(AppDir::new(tmp_app.path().to_path_buf())),
691 };
692 std::mem::forget(tmp_app);
693 AppService::new(executor, log_config, vec![])
694 }
695
696 #[tokio::test]
699 async fn maybe_log_transcript_returns_some_on_write_failure() {
700 let tmp = tempfile::tempdir().expect("test tempdir");
701 let log_dir = tmp.path().to_path_buf();
702 std::fs::create_dir_all(log_dir.join("fail-session.json"))
704 .expect("pre-create dir to block write");
705 let svc = make_app_service_with_log_dir(log_dir).await;
706 let metrics = make_metrics_with_transcript();
707 let result = make_finished_result(metrics);
708 let warning = svc.maybe_log_transcript(&result, "fail-session");
709 assert!(warning.is_some(), "expected Some warning on write failure");
710 let msg = warning.unwrap();
711 assert!(
712 msg.contains("transcript"),
713 "warning should mention 'transcript', got: {msg}"
714 );
715 }
716
717 #[tokio::test]
718 async fn maybe_log_transcript_returns_none_on_non_finished() {
719 let tmp = tempfile::tempdir().expect("test tempdir");
720 let svc = make_app_service_with_log_dir(tmp.path().to_path_buf()).await;
721 let result = FeedResult::Accepted { remaining: 1 };
722 let warning = svc.maybe_log_transcript(&result, "any-session");
723 assert!(warning.is_none(), "Accepted result should return None");
724 }
725
726 #[test]
729 fn splice_transcript_warning_injects_field_when_some() {
730 let json = r#"{"status":"finished","result":{}}"#;
731 let out = splice_transcript_warning(json, Some("write failed".to_string()));
732 let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
733 assert_eq!(
734 v["transcript_warning"].as_str(),
735 Some("write failed"),
736 "transcript_warning field should be present"
737 );
738 assert_eq!(v["status"].as_str(), Some("finished"));
740 }
741
742 #[test]
743 fn splice_transcript_warning_passthrough_when_none() {
744 let json = r#"{"status":"finished"}"#;
745 let out = splice_transcript_warning(json, None);
746 let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
747 assert!(
748 v.get("transcript_warning").is_none(),
749 "transcript_warning must be absent when warning is None"
750 );
751 }
752
753 use crate::pool::PoolSessionEntry;
756
757 #[tokio::test]
763 async fn continue_single_in_mcp_path_on_registry_miss() {
764 let tmp = tempfile::tempdir().expect("test tempdir");
765 let svc = make_app_service_with_log_dir(tmp.path().to_path_buf()).await;
766
767 let result = svc
771 .continue_single(
772 "nonexistent-session-id",
773 "some response".to_string(),
774 None,
775 None,
776 )
777 .await;
778 assert!(
779 result.is_err(),
780 "unknown session must return Err on in-MCP path"
781 );
782 let msg = result.unwrap_err();
783 assert!(
784 msg.contains("not found") || msg.contains("Continue failed"),
785 "error must indicate session not found, got: {msg}"
786 );
787 }
788
789 #[tokio::test]
794 async fn app_service_new_initialises_empty_pool_registry() {
795 let tmp = tempfile::tempdir().expect("test tempdir");
796 let svc = make_app_service_with_log_dir(tmp.path().to_path_buf()).await;
797
798 let reg = svc.pool_registry.read().await;
799 assert!(
800 reg.sessions.is_empty(),
801 "pool registry must be empty on first-run (no registry.json)"
802 );
803 }
804
805 #[tokio::test]
810 async fn app_service_pool_paths_correctly_derived() {
811 let tmp = tempfile::tempdir().expect("test tempdir");
812 let svc = make_app_service_with_log_dir(tmp.path().to_path_buf()).await;
813
814 assert!(
815 svc.pool_dir.ends_with("pool"),
816 "pool_dir must end in 'pool', got: {}",
817 svc.pool_dir.display()
818 );
819 assert!(
820 svc.pool_reg_path.ends_with("pool/registry.json"),
821 "pool_reg_path must end in 'pool/registry.json', got: {}",
822 svc.pool_reg_path.display()
823 );
824 assert!(
825 svc.pool_lock_path.ends_with("pool/registry.lock"),
826 "pool_lock_path must end in 'pool/registry.lock', got: {}",
827 svc.pool_lock_path.display()
828 );
829 }
830
831 #[tokio::test]
837 async fn continue_single_propagates_corrupted_registry_error() {
838 let tmp = tempfile::tempdir().expect("test tempdir");
839 let svc = make_app_service_with_log_dir(tmp.path().to_path_buf()).await;
840
841 let pool_dir = svc.pool_dir.clone();
843 std::fs::create_dir_all(&pool_dir).expect("create pool dir");
844 std::fs::write(pool_dir.join("registry.json"), b"{ not valid json !!!")
845 .expect("write corrupt registry");
846
847 let result = svc
851 .continue_single("any-session-id", "response".to_string(), None, None)
852 .await;
853 assert!(
854 result.is_err(),
855 "corrupted registry must cause Err, not silent empty fallback"
856 );
857 let msg = result.unwrap_err();
858 assert!(
859 msg.contains("corrupted") || msg.contains("parse") || msg.contains("Continue failed"),
860 "error must mention registry problem, got: {msg}"
861 );
862 }
863
864 use super::super::eval_store::splice_response_string;
867
868 #[test]
874 fn splice_response_string_injects_cache_reload_warning() {
875 let json = r#"{"status":"finished","result":{"ok":true}}"#;
876 let msg = "failed to reload pool registry: No such file or directory";
877 let out = splice_response_string(json, "pool_cache_reload_warning", msg);
878 let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
879 assert_eq!(
880 v["pool_cache_reload_warning"].as_str(),
881 Some(msg),
882 "pool_cache_reload_warning must be present in response"
883 );
884 assert_eq!(v["status"].as_str(), Some("finished"));
886 }
887
888 #[test]
893 fn splice_response_string_passthrough_on_non_object_json() {
894 let non_object = r#""just a string""#;
895 let out = splice_response_string(non_object, "pool_cache_reload_warning", "err");
896 assert_eq!(out, non_object);
898 }
899
900 #[test]
906 fn splice_response_string_not_called_when_none() {
907 let json = r#"{"status":"finished"}"#;
908 let v: serde_json::Value = serde_json::from_str(json).expect("valid JSON");
910 assert!(
911 v.get("pool_cache_reload_warning").is_none(),
912 "pool_cache_reload_warning must be absent when no cache-reload error occurred"
913 );
914 }
915
916 #[tokio::test]
922 async fn continue_single_routes_to_pool_on_registry_hit() {
923 let tmp = tempfile::tempdir().expect("test tempdir");
924 let svc = make_app_service_with_log_dir(tmp.path().to_path_buf()).await;
925
926 let fake_sock = tmp.path().join("nonexistent.sock");
929 let entry = PoolSessionEntry::new(
930 "test-pool-session",
931 std::process::id(), fake_sock.clone(),
933 env!("CARGO_PKG_VERSION"),
934 );
935 {
936 let mut reg = svc.pool_registry.write().await;
937 reg.add(entry);
938 }
939
940 let result = svc
944 .continue_single("test-pool-session", "response".to_string(), None, None)
945 .await;
946 assert!(
947 result.is_err(),
948 "pool path must fail with connect error (no real worker)"
949 );
950 let msg = result.unwrap_err();
951 assert!(
954 !msg.contains("session not found") || msg.contains("Continue failed"),
955 "error must be from pool path (UDS connect), got: {msg}"
956 );
957 }
958
959 use super::resolve_env;
962
963 #[test]
968 fn resolve_env_inject_keys_readable() {
969 let ctx = serde_json::json!({
970 "env": {
971 "inject": { "FOO": "bar", "BAZ": "qux" }
972 }
973 });
974 let map = resolve_env(&ctx, None, None).expect("resolve_env should succeed");
975 assert_eq!(map.get("FOO").map(String::as_str), Some("bar"));
976 assert_eq!(map.get("BAZ").map(String::as_str), Some("qux"));
977 }
978
979 #[test]
984 fn resolve_env_empty_ctx_produces_empty_map() {
985 let ctx = serde_json::Value::Null;
986 let map = resolve_env(&ctx, None, None).expect("resolve_env with null ctx should succeed");
987 assert!(map.is_empty(), "empty ctx must produce an empty env map");
988 }
989
990 #[test]
994 fn resolve_env_inject_overwrites_dotenv() {
995 let tmp = tempfile::tempdir().expect("test tempdir");
996 let env_file = tmp.path().join(".env");
997 std::fs::write(&env_file, b"PRIORITY=from_dotenv\n").expect("write .env");
999
1000 let ctx = serde_json::json!({
1001 "env": {
1002 "dotenv": env_file.to_str().expect("valid path"),
1003 "inject": { "PRIORITY": "from_inject" }
1004 }
1005 });
1006 let map = resolve_env(&ctx, None, None).expect("resolve_env should succeed");
1007 assert_eq!(
1009 map.get("PRIORITY").map(String::as_str),
1010 Some("from_inject"),
1011 "inject must shadow dotenv for the same key"
1012 );
1013 }
1014
1015 #[test]
1019 fn resolve_env_dotenv_absolute_path_loaded() {
1020 let tmp = tempfile::tempdir().expect("test tempdir");
1021 let env_file = tmp.path().join(".env");
1022 std::fs::write(&env_file, b"DOTENV_KEY=dotenv_val\n").expect("write .env");
1023
1024 let ctx = serde_json::json!({
1025 "env": {
1026 "dotenv": env_file.to_str().expect("valid path")
1027 }
1028 });
1029 let map = resolve_env(&ctx, None, None).expect("resolve_env should succeed");
1030 assert_eq!(
1031 map.get("DOTENV_KEY").map(String::as_str),
1032 Some("dotenv_val"),
1033 "key from dotenv file must be accessible"
1034 );
1035 }
1036
1037 #[test]
1041 fn resolve_env_allowlist_filters_inject_keys() {
1042 let ctx = serde_json::json!({
1043 "env": {
1044 "inject": { "ALLOWED": "yes", "BLOCKED": "no" }
1045 }
1046 });
1047 let allow = vec!["ALLOWED".to_string()];
1048 let map =
1049 resolve_env(&ctx, None, Some(allow.as_slice())).expect("resolve_env should succeed");
1050 assert_eq!(map.get("ALLOWED").map(String::as_str), Some("yes"));
1051 assert!(
1052 map.get("BLOCKED").is_none(),
1053 "BLOCKED key must be excluded by allowlist"
1054 );
1055 }
1056
1057 #[test]
1061 fn resolve_env_allow_os_false_excludes_os_vars() {
1062 let ctx = serde_json::json!({ "env": { "allow_os": false } });
1064 let map = resolve_env(&ctx, None, None).expect("resolve_env should succeed");
1065 assert!(
1067 map.get("PATH").is_none(),
1068 "OS env must not leak when allow_os is false"
1069 );
1070 }
1071
1072 #[test]
1076 fn resolve_env_relative_dotenv_without_project_root_errors() {
1077 let ctx = serde_json::json!({
1078 "env": { "dotenv": ".env" }
1079 });
1080 let result = resolve_env(&ctx, None, None);
1081 assert!(
1082 result.is_err(),
1083 "relative dotenv path without project_root must return Err"
1084 );
1085 let msg = result.unwrap_err();
1086 assert!(
1087 msg.contains("project_root"),
1088 "error must mention project_root, got: {msg}"
1089 );
1090 }
1091
1092 #[test]
1096 fn resolve_env_relative_dotenv_with_project_root_resolved() {
1097 let tmp = tempfile::tempdir().expect("test tempdir");
1098 std::fs::write(tmp.path().join(".env"), b"REL_KEY=rel_val\n").expect("write .env");
1099
1100 let ctx = serde_json::json!({ "env": { "dotenv": ".env" } });
1101 let map = resolve_env(&ctx, Some(tmp.path()), None).expect("resolve_env should succeed");
1102 assert_eq!(
1103 map.get("REL_KEY").map(String::as_str),
1104 Some("rel_val"),
1105 "relative dotenv path must be resolved against project_root"
1106 );
1107 }
1108
1109 #[test]
1111 fn resolve_env_none_allowlist_keeps_all_inject_keys() {
1112 let ctx = serde_json::json!({
1113 "env": { "inject": { "A": "1", "B": "2" } }
1114 });
1115 let map = resolve_env(&ctx, None, None).expect("resolve_env should succeed");
1116 assert_eq!(
1117 map.len(),
1118 2,
1119 "all inject keys must be retained when allowlist is None"
1120 );
1121 }
1122
1123 #[test]
1127 fn resolve_env_inject_non_string_value_errors() {
1128 let ctx = serde_json::json!({
1129 "env": { "inject": { "BAD": 42 } }
1130 });
1131 let result = resolve_env(&ctx, None, None);
1132 assert!(result.is_err(), "non-string inject value must return Err");
1133 let msg = result.unwrap_err();
1134 assert!(
1135 msg.contains("BAD"),
1136 "error must mention the offending key, got: {msg}"
1137 );
1138 }
1139
1140 #[test]
1142 fn resolve_env_inject_not_an_object_errors() {
1143 let ctx = serde_json::json!({
1144 "env": { "inject": ["not", "an", "object"] }
1145 });
1146 let result = resolve_env(&ctx, None, None);
1147 assert!(result.is_err(), "non-object inject value must return Err");
1148 }
1149
1150 #[test]
1154 fn resolve_env_missing_dotenv_file_errors() {
1155 let ctx = serde_json::json!({
1156 "env": { "dotenv": "/nonexistent/path/to/.env" }
1157 });
1158 let result = resolve_env(&ctx, None, None);
1159 assert!(
1160 result.is_err(),
1161 "missing dotenv file must return Err, not empty map"
1162 );
1163 let msg = result.unwrap_err();
1164 assert!(
1165 msg.contains("dotenv"),
1166 "error must mention dotenv, got: {msg}"
1167 );
1168 }
1169}