aboutsummaryrefslogtreecommitdiff
path: root/src/detect/manifest
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-10 15:24:24 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-10 15:33:45 +0300
commit36a1b7f86444871f0c38ff36510a6e3c2eec48c6 (patch)
tree4a2d100eef7f26af7feaa93ce661423c3994c54e /src/detect/manifest
parent8d13492926829c5cf0ea42c8613344cd65ff59fa (diff)
feat: distribute agent detection manifests
Diffstat (limited to 'src/detect/manifest')
-rw-r--r--src/detect/manifest/tests.rs498
1 files changed, 498 insertions, 0 deletions
diff --git a/src/detect/manifest/tests.rs b/src/detect/manifest/tests.rs
new file mode 100644
index 00000000..e5b03c0b
--- /dev/null
+++ b/src/detect/manifest/tests.rs
@@ -0,0 +1,498 @@
+use super::*;
+
+fn remote_manifest(version: &str, state: &str, contains: &str) -> String {
+ format!(
+ r#"
+id = "codex"
+version = "{version}"
+min_engine_version = 1
+updated_at = "2026-06-10T12:00:00Z"
+
+[[rules]]
+id = "test"
+state = "{state}"
+contains = ["{contains}"]
+"#
+ )
+}
+
+fn local_manifest(state: &str, contains: &str) -> String {
+ format!(
+ r#"
+id = "codex"
+
+[[rules]]
+id = "test"
+state = "{state}"
+contains = ["{contains}"]
+"#
+ )
+}
+
+fn rules_manifest(rules: &str) -> String {
+ format!(
+ r#"
+id = "codex"
+
+{rules}
+"#
+ )
+}
+
+fn with_manifest_dirs<T>(name: &str, f: impl FnOnce() -> T) -> T {
+ let _guard = crate::config::test_config_env_lock().lock().unwrap();
+ let old_config = std::env::var_os("XDG_CONFIG_HOME");
+ let old_state = std::env::var_os("XDG_STATE_HOME");
+ let base = std::env::temp_dir().join(format!(
+ "herdr-manifest-loader-{name}-{}",
+ std::process::id()
+ ));
+ let config_dir = base.join("config");
+ let state_dir = base.join("state");
+ let _ = std::fs::remove_dir_all(&base);
+ std::env::set_var("XDG_CONFIG_HOME", &config_dir);
+ std::env::set_var("XDG_STATE_HOME", &state_dir);
+ reload_manifests();
+ let result = f();
+ match old_config {
+ Some(value) => std::env::set_var("XDG_CONFIG_HOME", value),
+ None => std::env::remove_var("XDG_CONFIG_HOME"),
+ }
+ match old_state {
+ Some(value) => std::env::set_var("XDG_STATE_HOME", value),
+ None => std::env::remove_var("XDG_STATE_HOME"),
+ }
+ reload_manifests();
+ let _ = std::fs::remove_dir_all(&base);
+ result
+}
+
+fn write_remote_codex(content: &str) {
+ let path = crate::detect::manifest_update::remote_manifest_path(Agent::Codex);
+ std::fs::create_dir_all(path.parent().unwrap()).unwrap();
+ std::fs::write(path, content).unwrap();
+ reload_manifests();
+}
+
+fn write_remote_codex_without_reload(content: &str) {
+ let path = crate::detect::manifest_update::remote_manifest_path(Agent::Codex);
+ std::fs::create_dir_all(path.parent().unwrap()).unwrap();
+ std::fs::write(path, content).unwrap();
+}
+
+fn write_local_codex(content: &str) {
+ let path = override_path(Agent::Codex).unwrap();
+ std::fs::create_dir_all(path.parent().unwrap()).unwrap();
+ std::fs::write(path, content).unwrap();
+ reload_manifests();
+}
+
+#[test]
+fn known_agent_no_match_defaults_to_idle_fallback() {
+ let explain = explain(Agent::Codex, "ordinary prompt text");
+
+ assert_eq!(explain.state, AgentState::Idle);
+ assert!(!explain.visible_idle);
+ assert_eq!(
+ explain.fallback_reason.as_deref(),
+ Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK)
+ );
+}
+
+#[test]
+fn rule_semantics_apply_gates_priority_and_line_regex() {
+ with_manifest_dirs("rule-semantics", || {
+ write_local_codex(&rules_manifest(
+ r#"
+[[rules]]
+id = "low_contains"
+state = "idle"
+priority = 1
+contains = ["match"]
+
+[[rules]]
+id = "high_nested_gates"
+state = "working"
+priority = 10
+contains = ["match"]
+all = [
+ { any = [{ regex = ["w[io]n"] }, { contains = ["fallback"] }] },
+]
+not = [
+ { contains = ["blocked"] },
+]
+
+[[rules]]
+id = "line_regex"
+state = "blocked"
+priority = 20
+line_regex = ["^exact line$"]
+"#,
+ ));
+
+ let high = explain(Agent::Codex, "match win");
+ assert_eq!(high.state, AgentState::Working);
+ assert_eq!(
+ high.matched_rule.as_ref().map(|rule| rule.id.as_str()),
+ Some("high_nested_gates")
+ );
+
+ let not_gate = explain(Agent::Codex, "match win blocked");
+ assert_eq!(not_gate.state, AgentState::Idle);
+ assert_eq!(
+ not_gate.matched_rule.as_ref().map(|rule| rule.id.as_str()),
+ Some("low_contains")
+ );
+
+ let line = explain(Agent::Codex, "before\nexact line\nafter");
+ assert_eq!(line.state, AgentState::Blocked);
+ assert_eq!(
+ line.matched_rule.as_ref().map(|rule| rule.id.as_str()),
+ Some("line_regex")
+ );
+ });
+}
+
+#[test]
+fn remote_manifest_loads_between_local_override_and_bundled() {
+ with_manifest_dirs("remote-source", || {
+ write_remote_codex(&remote_manifest("2026.06.10.1", "blocked", "remote-ready"));
+
+ let explain = explain(Agent::Codex, "remote-ready");
+
+ assert_eq!(explain.state, AgentState::Blocked);
+ assert!(matches!(
+ explain.source,
+ Some(ManifestSource::Remote { .. })
+ ));
+ assert_eq!(explain.manifest_version.as_deref(), Some("2026.06.10.1"));
+ assert_eq!(
+ explain.cached_remote_version.as_deref(),
+ Some("2026.06.10.1")
+ );
+ });
+}
+
+#[test]
+fn fallback_explain_preserves_active_manifest_version() {
+ with_manifest_dirs("fallback-version", || {
+ write_remote_codex(&remote_manifest("2026.06.10.1", "blocked", "remote-ready"));
+
+ let explain = explain(Agent::Codex, "ordinary prompt text");
+
+ assert_eq!(explain.state, AgentState::Idle);
+ assert_eq!(
+ explain.fallback_reason.as_deref(),
+ Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK)
+ );
+ assert_eq!(explain.manifest_version.as_deref(), Some("2026.06.10.1"));
+ assert!(matches!(
+ explain.source,
+ Some(ManifestSource::Remote { .. })
+ ));
+ });
+}
+
+#[test]
+fn older_cached_remote_manifest_does_not_shadow_newer_bundled_manifest() {
+ with_manifest_dirs("older-remote-bundled-fallback", || {
+ write_remote_codex(&remote_manifest("2026.06.10.0", "blocked", "remote-ready"));
+
+ let explain = explain(Agent::Codex, "remote-ready");
+
+ assert_eq!(explain.state, AgentState::Idle);
+ assert!(matches!(explain.source, Some(ManifestSource::Bundled)));
+ assert_eq!(
+ explain.cached_remote_version.as_deref(),
+ Some("2026.06.10.0")
+ );
+ assert!(explain
+ .warning
+ .as_deref()
+ .is_some_and(|warning| warning.contains("older than bundled")));
+ });
+}
+
+#[test]
+fn local_override_shadows_cached_remote_manifest() {
+ with_manifest_dirs("local-shadows-remote", || {
+ write_remote_codex(&remote_manifest("2026.06.10.1", "blocked", "remote-ready"));
+ write_local_codex(&local_manifest("idle", "local-ready"));
+
+ let explain = explain(Agent::Codex, "local-ready");
+
+ assert_eq!(explain.state, AgentState::Idle);
+ assert!(matches!(explain.source, Some(ManifestSource::Override(_))));
+ assert!(explain.local_override_shadowing_remote);
+ assert_eq!(
+ explain.cached_remote_version.as_deref(),
+ Some("2026.06.10.1")
+ );
+ });
+}
+
+#[test]
+fn invalid_local_override_falls_back_to_cached_remote_manifest() {
+ with_manifest_dirs("invalid-local-remote-fallback", || {
+ write_remote_codex(&remote_manifest("2026.06.10.1", "blocked", "remote-ready"));
+ write_local_codex("id = ");
+
+ let explain = explain(Agent::Codex, "remote-ready");
+
+ assert_eq!(explain.state, AgentState::Blocked);
+ assert!(matches!(
+ explain.source,
+ Some(ManifestSource::Remote { .. })
+ ));
+ assert!(explain.warning.is_some());
+ });
+}
+
+#[test]
+fn detection_uses_cached_manifest_until_explicit_reload() {
+ with_manifest_dirs("cache-boundary", || {
+ write_remote_codex(&remote_manifest("2026.06.10.1", "blocked", "cached-ready"));
+
+ let cached = explain(Agent::Codex, "cached-ready");
+ assert_eq!(cached.state, AgentState::Blocked);
+ assert!(matches!(cached.source, Some(ManifestSource::Remote { .. })));
+ assert_eq!(
+ cached.matched_rule.as_ref().map(|rule| rule.id.as_str()),
+ Some("test")
+ );
+
+ write_remote_codex_without_reload(&remote_manifest("2026.06.10.2", "working", "new-ready"));
+
+ let unchanged = explain(Agent::Codex, "new-ready");
+ assert_eq!(unchanged.state, AgentState::Idle);
+ assert_eq!(
+ unchanged.fallback_reason.as_deref(),
+ Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK)
+ );
+ assert_eq!(
+ unchanged.cached_remote_version.as_deref(),
+ Some("2026.06.10.1")
+ );
+
+ reload_manifests();
+
+ let reloaded = explain(Agent::Codex, "new-ready");
+ assert_eq!(reloaded.state, AgentState::Working);
+ assert_eq!(
+ reloaded.cached_remote_version.as_deref(),
+ Some("2026.06.10.2")
+ );
+ assert_eq!(
+ reloaded.matched_rule.as_ref().map(|rule| rule.id.as_str()),
+ Some("test")
+ );
+ });
+}
+
+#[test]
+fn all_bundled_manifests_parse_and_validate() {
+ let agents = [
+ Agent::Pi,
+ Agent::Claude,
+ Agent::Codex,
+ Agent::Gemini,
+ Agent::Cursor,
+ Agent::Antigravity,
+ Agent::Cline,
+ Agent::OpenCode,
+ Agent::GithubCopilot,
+ Agent::Kimi,
+ Agent::Kiro,
+ Agent::Droid,
+ Agent::Amp,
+ Agent::Grok,
+ Agent::Hermes,
+ Agent::Kilo,
+ Agent::Qodercli,
+ ];
+
+ for agent in agents {
+ assert!(
+ bundled_manifest(agent).is_some(),
+ "missing bundled manifest for {}",
+ agent_label(agent)
+ );
+ }
+}
+
+#[test]
+fn manifest_validation_rejects_unknown_fields_empty_rules_invalid_regions_and_regexes() {
+ assert!(parse_manifest(
+ r#"
+id = "codex"
+
+[[rules]]
+id = "typo"
+state = "working"
+contain = ["Working"]
+"#
+ )
+ .is_err());
+
+ assert!(parse_manifest(
+ r#"
+id = "codex"
+
+[[rules]]
+id = "empty"
+state = "working"
+"#
+ )
+ .is_err());
+
+ assert!(parse_manifest(
+ r#"
+id = "codex"
+
+[[rules]]
+id = "bad_region"
+state = "working"
+region = "after_last_promt_marker"
+contains = ["Working"]
+"#
+ )
+ .is_err());
+
+ assert!(parse_manifest(
+ r#"
+id = "codex"
+
+[[rules]]
+id = "bad_regex"
+state = "working"
+regex = ["["]
+"#
+ )
+ .is_err());
+
+ assert!(parse_manifest(
+ r#"
+id = "codex"
+
+[[rules]]
+id = "bad_nested_regex"
+state = "working"
+any = [{ line_regex = ["["] }]
+"#
+ )
+ .is_err());
+}
+
+#[test]
+fn manifest_validation_keeps_skip_rules_neutral() {
+ assert!(parse_manifest(
+ r#"
+id = "codex"
+
+[[rules]]
+id = "bad_skip_state"
+state = "idle"
+skip_state_update = true
+contains = ["menu"]
+"#
+ )
+ .is_err());
+
+ assert!(parse_manifest(
+ r#"
+id = "codex"
+
+[[rules]]
+id = "bad_skip_visible"
+state = "unknown"
+skip_state_update = true
+visible_blocker = true
+contains = ["menu"]
+"#
+ )
+ .is_err());
+}
+
+#[test]
+fn manifest_validation_rejects_excessive_rule_count() {
+ let mut manifest = String::from(
+ r#"
+id = "codex"
+"#,
+ );
+ for index in 0..129 {
+ manifest.push_str(&format!(
+ r#"
+[[rules]]
+id = "rule_{index}"
+state = "idle"
+contains = ["ready"]
+"#
+ ));
+ }
+
+ assert!(parse_manifest(&manifest).is_err());
+}
+
+#[test]
+fn manifest_validation_rejects_excessive_gate_depth() {
+ let manifest = r#"
+id = "codex"
+
+[[rules]]
+id = "deep"
+state = "idle"
+contains = ["ready"]
+all = [
+ { contains = ["1"], all = [
+ { contains = ["2"], all = [
+ { contains = ["3"], all = [
+ { contains = ["4"], all = [
+ { contains = ["5"], all = [
+ { contains = ["6"], all = [
+ { contains = ["7"], all = [
+ { contains = ["8"], all = [
+ { contains = ["9"] },
+ ] },
+ ] },
+ ] },
+ ] },
+ ] },
+ ] },
+ ] },
+ ] },
+]
+"#;
+
+ assert!(parse_manifest(manifest).is_err());
+}
+
+#[test]
+fn manifest_validation_rejects_excessive_matchers() {
+ let matchers = (0..33)
+ .map(|index| format!(r#""m{index}""#))
+ .collect::<Vec<_>>()
+ .join(", ");
+ let manifest = format!(
+ r#"
+id = "codex"
+
+[[rules]]
+id = "many"
+state = "idle"
+contains = [{matchers}]
+"#
+ );
+
+ assert!(parse_manifest(&manifest).is_err());
+}
+
+#[test]
+fn bottom_non_empty_lines_uses_bottom_occurrence_for_repeated_text() {
+ let content = "marker\nold\n\nmiddle\nmarker\nnew\n";
+
+ assert_eq!(
+ region(content, "bottom_non_empty_lines(2)"),
+ "marker\nnew\n"
+ );
+}