use lean_ctx::core::workflow::*; fn minimal_spec() -> WorkflowSpec { WorkflowSpec { name: "test".to_string(), description: None, initial: "a".to_string(), states: vec![ StateSpec { name: "a".to_string(), description: None, allowed_tools: Some(vec!["ctx_read".to_string()]), requires_evidence: None, }, StateSpec { name: "b".to_string(), description: None, allowed_tools: None, requires_evidence: Some(vec!["tool:ctx_read".to_string()]), }, StateSpec { name: "done".to_string(), description: None, allowed_tools: None, requires_evidence: None, }, ], transitions: vec![ TransitionSpec { from: "a".to_string(), to: "b".to_string(), description: None, }, TransitionSpec { from: "b".to_string(), to: "done".to_string(), description: None, }, ], } } struct EnvVarGuard { key: &'static str, previous: Option, } impl EnvVarGuard { fn set(key: &'static str, value: &str) -> Self { let previous = std::env::var(key).ok(); // TODO: Audit that the environment access only happens in single-threaded code. unsafe { std::env::set_var(key, value) }; Self { key, previous } } } impl Drop for EnvVarGuard { fn drop(&mut self) { if let Some(ref previous) = self.previous { // TODO: Audit that the environment access only happens in single-threaded code. unsafe { std::env::set_var(self.key, previous) }; } else { // TODO: Audit that the environment access only happens in single-threaded code. unsafe { std::env::remove_var(self.key) }; } } } #[test] fn validate_spec_accepts_valid_spec() { let spec = minimal_spec(); assert!(validate_spec(&spec).is_ok()); } #[test] fn validate_spec_rejects_empty_states() { let mut spec = minimal_spec(); spec.states.clear(); let err = validate_spec(&spec).unwrap_err(); assert!(err.contains("must not be empty")); } #[test] fn validate_spec_rejects_empty_name() { let mut spec = minimal_spec(); spec.name = " ".to_string(); let err = validate_spec(&spec).unwrap_err(); assert!(err.contains("name must not be empty")); } #[test] fn validate_spec_rejects_duplicate_state_names() { let mut spec = minimal_spec(); spec.states.push(StateSpec { name: "a".to_string(), description: None, allowed_tools: None, requires_evidence: None, }); let err = validate_spec(&spec).unwrap_err(); assert!(err.contains("Duplicate state name")); } #[test] fn validate_spec_rejects_initial_not_in_states() { let mut spec = minimal_spec(); spec.initial = "nonexistent".to_string(); let err = validate_spec(&spec).unwrap_err(); assert!(err.contains("is not in states")); } #[test] fn validate_spec_rejects_transition_with_unknown_from() { let mut spec = minimal_spec(); spec.transitions.push(TransitionSpec { from: "ghost".to_string(), to: "a".to_string(), description: None, }); let err = validate_spec(&spec).unwrap_err(); assert!(err.contains("is not a state")); } #[test] fn validate_spec_rejects_empty_allowed_tools_list() { let mut spec = minimal_spec(); spec.states[0].allowed_tools = Some(vec![]); let err = validate_spec(&spec).unwrap_err(); assert!(err.contains("must not be empty when present")); } #[test] fn allowed_transitions_returns_correct_transitions() { let spec = minimal_spec(); let from_a = allowed_transitions(&spec, "a"); assert_eq!(from_a.len(), 1); assert_eq!(from_a[0].to, "b"); let from_done = allowed_transitions(&spec, "done"); assert!(from_done.is_empty()); } #[test] fn find_transition_works() { let spec = minimal_spec(); assert!(find_transition(&spec, "a", "b").is_some()); assert!(find_transition(&spec, "a", "done").is_none()); assert!(find_transition(&spec, "b", "a").is_none()); } #[test] fn missing_evidence_returns_empty_when_no_requirements() { let spec = minimal_spec(); let missing = missing_evidence_for_state(&spec, "a", |_| false); assert!(missing.is_empty()); } #[test] fn missing_evidence_returns_keys_when_unsatisfied() { let spec = minimal_spec(); let missing = missing_evidence_for_state(&spec, "b", |_| false); assert_eq!(missing, vec!["tool:ctx_read"]); } #[test] fn missing_evidence_returns_empty_when_satisfied() { let spec = minimal_spec(); let missing = missing_evidence_for_state(&spec, "b", |k| k == "tool:ctx_read"); assert!(missing.is_empty()); } #[test] fn missing_evidence_unknown_state() { let spec = minimal_spec(); let missing = missing_evidence_for_state(&spec, "nonexistent", |_| true); assert_eq!(missing.len(), 1); assert!(missing[0].starts_with("unknown_state:")); } #[test] fn can_transition_succeeds_with_evidence() { let spec = minimal_spec(); let result = can_transition(&spec, "a", "b", |k| k == "tool:ctx_read"); assert!(result.is_ok()); } #[test] fn can_transition_blocked_without_evidence() { let spec = minimal_spec(); let result = can_transition(&spec, "a", "b", |_| false); assert!(result.is_err()); assert!(result.unwrap_err().contains("Missing evidence")); } #[test] fn can_transition_blocked_for_invalid_transition() { let spec = minimal_spec(); let result = can_transition(&spec, "a", "done", |_| true); assert!(result.is_err()); assert!(result.unwrap_err().contains("No transition")); } #[test] fn workflow_run_new_sets_initial_state() { let spec = minimal_spec(); let run = WorkflowRun::new(spec); assert_eq!(run.current, "a"); assert!(run.transitions.is_empty()); assert!(run.evidence.is_empty()); } #[test] fn workflow_run_add_manual_evidence() { let spec = minimal_spec(); let mut run = WorkflowRun::new(spec); run.add_manual_evidence("tool:ctx_read", Some("file.rs")); assert_eq!(run.evidence.len(), 1); assert_eq!(run.evidence[0].key, "tool:ctx_read"); assert_eq!(run.evidence[0].value.as_deref(), Some("file.rs")); } #[test] fn builtin_plan_code_test_validates() { let spec = WorkflowSpec::builtin_plan_code_test(); assert!(validate_spec(&spec).is_ok()); assert_eq!(spec.initial, "planning"); assert_eq!(spec.states.len(), 4); assert_eq!(spec.transitions.len(), 3); } #[test] fn builtin_spec_roundtrip_serde() { let spec = WorkflowSpec::builtin_plan_code_test(); let json = serde_json::to_string(&spec).expect("serialize"); let back: WorkflowSpec = serde_json::from_str(&json).expect("deserialize"); assert_eq!(back.name, spec.name); assert_eq!(back.states.len(), spec.states.len()); } #[test] fn store_roundtrip() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("active.json"); let spec = minimal_spec(); let mut run = WorkflowRun::new(spec); run.add_manual_evidence("test-key", Some("test-value")); let json = serde_json::to_string_pretty(&run).expect("serialize"); std::fs::write(&path, &json).expect("write"); let content = std::fs::read_to_string(&path).expect("read"); let loaded: WorkflowRun = serde_json::from_str(&content).expect("deserialize"); assert_eq!(loaded.current, "a"); assert_eq!(loaded.evidence.len(), 1); assert_eq!(loaded.evidence[0].key, "test-key"); } #[test] fn store_load_active_returns_none_when_file_is_missing() { let _env_lock = lean_ctx::core::data_dir::test_env_lock(); let tmp = tempfile::tempdir().expect("tempdir"); let data_dir = tmp.path().join("leanctx-data"); let _env = EnvVarGuard::set( "LEAN_CTX_DATA_DIR", data_dir.to_str().expect("data dir utf8"), ); clear_active().expect("clear missing active should succeed"); let loaded = load_active().expect("load_active"); assert!(loaded.is_none()); } #[test] fn store_save_load_clear_active_roundtrip() { let _env_lock = lean_ctx::core::data_dir::test_env_lock(); let tmp = tempfile::tempdir().expect("tempdir"); let data_dir = tmp.path().join("leanctx-data"); let _env = EnvVarGuard::set( "LEAN_CTX_DATA_DIR", data_dir.to_str().expect("data dir utf8"), ); let mut run = WorkflowRun::new(minimal_spec()); run.add_manual_evidence("tool:ctx_read", Some("src/lib.rs")); save_active(&run).expect("save_active"); let loaded = load_active() .expect("load_active") .expect("active workflow"); assert_eq!(loaded.current, "a"); assert_eq!(loaded.evidence.len(), 1); assert_eq!(loaded.evidence[0].key, "tool:ctx_read"); clear_active().expect("clear_active"); let after_clear = load_active().expect("load after clear"); assert!(after_clear.is_none()); }