diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-10 15:24:24 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-10 15:33:45 +0300 |
| commit | 36a1b7f86444871f0c38ff36510a6e3c2eec48c6 (patch) | |
| tree | 4a2d100eef7f26af7feaa93ce661423c3994c54e /src/detect/manifest | |
| parent | 8d13492926829c5cf0ea42c8613344cd65ff59fa (diff) | |
feat: distribute agent detection manifests
Diffstat (limited to 'src/detect/manifest')
| -rw-r--r-- | src/detect/manifest/tests.rs | 498 |
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" + ); +} |
