26382a7ac6
CI / Clippy (push) Failing after 15m13s
CI / Test (ubuntu-latest) (push) Failing after 16m1s
CI / Test (macos-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Build (no embeddings / no ORT) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Cookbook (Node) (push) Has been cancelled
CI / Pi Extension (Node) (push) Has been cancelled
CI / Rust SDK (lean-ctx-client) (push) Has been cancelled
CI / Embed SDK (lean-ctx-sdk) (push) Has been cancelled
CI / Python SDK (leanctx) (push) Has been cancelled
CI / Hermes Plugin (Python) (push) Has been cancelled
CI / SDK Conformance Matrix (push) Has been cancelled
CI / Coverage (push) Has been cancelled
CI / cargo-deny (push) Has been cancelled
CI / Adversarial Safety (push) Has been cancelled
CI / Benchmarks (push) Has been cancelled
CI / Output-Quality Gate (eval A/B) (push) Has been cancelled
CI / Documentation (push) Has been cancelled
CI / CI Green (push) Has been cancelled
JetBrains Plugin / Actionlint (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (rust) (push) Has been cancelled
JetBrains Plugin / Validation (push) Has been cancelled
JetBrains Plugin / Build (push) Has been cancelled
JetBrains Plugin / Test (push) Has been cancelled
Security Check / Security Scan (push) Has been cancelled
300 lines
9.0 KiB
Rust
300 lines
9.0 KiB
Rust
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<String>,
|
|
}
|
|
|
|
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());
|
|
}
|