use std::collections::BTreeMap;
use serde_json::{Map as JsonMap, Value as JsonValue};
use crate::stdlib::observability::{emit_instrument, MetricInstrument};
const COUNTER_SUGGESTED: &str = "harn.compass.suggested";
const COUNTER_REWRITTEN: &str = "harn.compass.rewritten";
const COUNTER_FELL_BACK: &str = "harn.compass.fell_back";
const DEFAULT_PERSONA: &str = "default";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum CompassMode {
Off,
Suggest,
Rewrite,
}
impl CompassMode {
fn parse(raw: &str) -> Option<Self> {
match raw.trim().to_ascii_lowercase().as_str() {
"off" | "disabled" | "none" => Some(Self::Off),
"suggest" | "advise" | "advisory" => Some(Self::Suggest),
"rewrite" | "auto" => Some(Self::Rewrite),
_ => None,
}
}
}
#[derive(Clone, Debug)]
pub(crate) struct CompassConfig {
pub mode: CompassMode,
pub persona: String,
pub prefer: Vec<String>,
}
impl CompassConfig {
pub(crate) fn from_options(options: &JsonValue) -> Self {
let persona = options
.get("persona")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(DEFAULT_PERSONA)
.to_string();
let compass = options.get("compass");
let mode = match compass {
None | Some(JsonValue::Null) => CompassMode::Suggest,
Some(JsonValue::Bool(false)) => CompassMode::Off,
Some(JsonValue::Bool(true)) => CompassMode::Suggest,
Some(JsonValue::Object(map)) => {
if map
.get("enabled")
.and_then(JsonValue::as_bool)
.is_some_and(|enabled| !enabled)
{
CompassMode::Off
} else {
map.get("mode")
.and_then(JsonValue::as_str)
.and_then(CompassMode::parse)
.unwrap_or(CompassMode::Suggest)
}
}
Some(_) => CompassMode::Suggest,
};
let prefer = compass
.and_then(|value| value.get("prefer"))
.or_else(|| {
options
.get("edit_strategy")
.and_then(|value| value.get("prefer"))
})
.and_then(JsonValue::as_array)
.map(|items| {
items
.iter()
.filter_map(JsonValue::as_str)
.map(str::to_string)
.collect()
})
.unwrap_or_default();
Self {
mode,
persona,
prefer,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum StructuralTarget {
ApplyNode,
RenameSymbol,
SafeTextPatch,
}
impl StructuralTarget {
pub(crate) fn tool_name(self) -> &'static str {
match self {
Self::ApplyNode => "edit_apply_node",
Self::RenameSymbol => "edit_rename_symbol",
Self::SafeTextPatch => "edit_safe_text_patch",
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum CompassDecision {
Passthrough,
Suggest {
target: StructuralTarget,
reminder_body: String,
},
Rewrite {
target: StructuralTarget,
tool_name: String,
tool_args: JsonValue,
},
}
#[derive(Clone, Debug)]
struct FreeformEdit {
path: Option<String>,
hunks: Vec<(String, String)>,
whole_file: bool,
}
fn parseable_extension(path: &str) -> bool {
const PARSEABLE: &[&str] = &[
"rs", "ts", "tsx", "js", "jsx", "mjs", "cjs", "py", "go", "java", "kt", "kts", "rb", "c",
"h", "cc", "cpp", "hpp", "cs", "swift", "scala", "php", "lua", "harn",
];
path.rsplit('.')
.next()
.map(|ext| PARSEABLE.contains(&ext.to_ascii_lowercase().as_str()))
.unwrap_or(false)
}
fn classify_freeform_edit(tool_name: &str, args: &JsonValue) -> Option<FreeformEdit> {
let normalized = tool_name.trim().to_ascii_lowercase();
if normalized.starts_with("edit_apply_node")
|| normalized.starts_with("edit_insert_at_anchor")
|| normalized.starts_with("edit_rename_symbol")
|| normalized.starts_with("edit_dry_run")
|| normalized.starts_with("edit_extract")
|| normalized.starts_with("edit_change_signature")
|| normalized.starts_with("edit_inline")
|| normalized.starts_with("edit_move")
{
return None;
}
let path = args
.get("path")
.or_else(|| args.get("file"))
.or_else(|| args.get("file_path"))
.and_then(JsonValue::as_str)
.map(str::to_string);
let is_replace_name = matches!(
normalized.as_str(),
"str_replace" | "str_replace_editor" | "apply_patch" | "edit_file" | "replace_in_file"
);
if is_replace_name {
let old_text = first_str(args, &["old_text", "old_str", "old", "search"]);
let new_text = first_str(args, &["new_text", "new_str", "new", "replace"]);
if let (Some(old_text), Some(new_text)) = (old_text, new_text) {
return Some(FreeformEdit {
path,
hunks: vec![(old_text, new_text)],
whole_file: false,
});
}
if let Some(hunks) = extract_hunks(args) {
return Some(FreeformEdit {
path,
hunks,
whole_file: false,
});
}
}
if normalized == "edit_safe_text_patch" {
let hunks = extract_hunks(args)?;
return Some(FreeformEdit {
path,
hunks,
whole_file: false,
});
}
let is_write_name = matches!(
normalized.as_str(),
"write_file" | "create_file" | "write" | "save_file"
);
if is_write_name {
let has_content = args.get("content").is_some() || args.get("contents").is_some();
if has_content {
return Some(FreeformEdit {
path,
hunks: Vec::new(),
whole_file: true,
});
}
}
None
}
fn first_str(args: &JsonValue, keys: &[&str]) -> Option<String> {
keys.iter()
.find_map(|key| args.get(*key).and_then(JsonValue::as_str))
.map(str::to_string)
}
fn extract_hunks(args: &JsonValue) -> Option<Vec<(String, String)>> {
let arr = args.get("hunks")?.as_array()?;
let mut hunks = Vec::with_capacity(arr.len());
for hunk in arr {
let old_text = first_str(hunk, &["old_text", "old_str", "old", "search"])?;
let new_text = first_str(hunk, &["new_text", "new_str", "new", "replace"])?;
hunks.push((old_text, new_text));
}
if hunks.is_empty() {
None
} else {
Some(hunks)
}
}
fn single_token_rename(hunks: &[(String, String)]) -> Option<(String, String)> {
if hunks.len() != 1 {
return None;
}
let (old_text, new_text) = &hunks[0];
let old_trim = old_text.trim();
let new_trim = new_text.trim();
if old_trim == new_trim || !is_bare_identifier(old_trim) || !is_bare_identifier(new_trim) {
return None;
}
Some((old_trim.to_string(), new_trim.to_string()))
}
fn is_bare_identifier(text: &str) -> bool {
let mut chars = text.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
fn rewrite_to_safe_text_patch(edit: &FreeformEdit, original: &JsonValue) -> Option<JsonValue> {
let path = edit.path.as_ref()?;
if edit.whole_file || edit.hunks.is_empty() {
return None;
}
let hunks: Vec<JsonValue> = edit
.hunks
.iter()
.map(|(old_text, new_text)| {
serde_json::json!({ "old_text": old_text, "new_text": new_text })
})
.collect();
let mut out = JsonMap::new();
out.insert("path".to_string(), JsonValue::String(path.clone()));
out.insert("hunks".to_string(), JsonValue::Array(hunks));
for passthrough in ["session_id", "match_options"] {
if let Some(value) = original.get(passthrough) {
if !value.is_null() {
out.insert(passthrough.to_string(), value.clone());
}
}
}
Some(JsonValue::Object(out))
}
fn prefers(config: &CompassConfig, target: StructuralTarget) -> bool {
config.prefer.iter().any(|name| name == target.tool_name())
}
pub(crate) fn route(
tool_name: &str,
tool_args: &JsonValue,
config: &CompassConfig,
) -> CompassDecision {
if config.mode == CompassMode::Off {
return CompassDecision::Passthrough;
}
let Some(edit) = classify_freeform_edit(tool_name, tool_args) else {
return CompassDecision::Passthrough;
};
if let Some(path) = edit.path.as_deref() {
if !parseable_extension(path) {
return CompassDecision::Passthrough;
}
}
let rename = single_token_rename(&edit.hunks);
let target = if rename.is_some() {
StructuralTarget::RenameSymbol
} else if edit.whole_file || prefers(config, StructuralTarget::ApplyNode) {
StructuralTarget::ApplyNode
} else {
StructuralTarget::SafeTextPatch
};
if config.mode == CompassMode::Rewrite {
let already_safe_patch = tool_name
.trim()
.eq_ignore_ascii_case("edit_safe_text_patch");
if !already_safe_patch && rename.is_none() && !edit.whole_file {
if let Some(new_args) = rewrite_to_safe_text_patch(&edit, tool_args) {
return CompassDecision::Rewrite {
target: StructuralTarget::SafeTextPatch,
tool_name: StructuralTarget::SafeTextPatch.tool_name().to_string(),
tool_args: new_args,
};
}
}
}
let reminder_body = suggestion_body(tool_name, target, rename.as_ref());
CompassDecision::Suggest {
target,
reminder_body,
}
}
fn suggestion_body(
tool_name: &str,
target: StructuralTarget,
rename: Option<&(String, String)>,
) -> String {
match target {
StructuralTarget::RenameSymbol => {
let detail = rename
.map(|(old, new)| format!(" Looks like a rename of `{old}` -> `{new}`; "))
.unwrap_or_else(|| " ".to_string());
format!(
"[compass] `{tool_name}` is a freeform edit on a parseable file.{detail}\
prefer `edit_rename_symbol` so every caller and import is updated atomically \
instead of a single string match. Preview with `edit_dry_run` first."
)
}
StructuralTarget::ApplyNode => format!(
"[compass] `{tool_name}` rewrites a whole parseable file. Prefer node-level \
`edit_apply_node` / `edit_insert_at_anchor` (target the changed declaration by AST \
query) so untouched code keeps its exact bytes, or `edit_dry_run` to preview a plan."
),
StructuralTarget::SafeTextPatch => format!(
"[compass] `{tool_name}` is a freeform text edit on a parseable file. Prefer a \
structural primitive (`edit_apply_node` for a node, `edit_rename_symbol` for a \
symbol) — or at least `edit_safe_text_patch`, which hash-guards the pre-image and \
writes atomically. Preview with `edit_dry_run`."
),
}
}
fn counter_attrs(
persona: &str,
freeform_tool: &str,
target: StructuralTarget,
) -> JsonMap<String, JsonValue> {
let mut attrs = JsonMap::new();
attrs.insert(
"harn.compass.persona".to_string(),
JsonValue::String(persona.to_string()),
);
attrs.insert(
"harn.compass.tool".to_string(),
JsonValue::String(freeform_tool.to_string()),
);
attrs.insert(
"harn.compass.target".to_string(),
JsonValue::String(target.tool_name().to_string()),
);
attrs
}
fn bump(name: &str, attrs: JsonMap<String, JsonValue>) {
let _ = emit_instrument(
MetricInstrument::Counter,
name.to_string(),
JsonValue::from(1),
attrs,
);
}
pub(crate) fn apply_decision(
decision: &CompassDecision,
original_tool: &str,
config: &CompassConfig,
fell_back: bool,
) -> Option<String> {
match decision {
CompassDecision::Passthrough => None,
CompassDecision::Rewrite { target, .. } => {
bump(
COUNTER_REWRITTEN,
counter_attrs(&config.persona, original_tool, *target),
);
None
}
CompassDecision::Suggest {
target,
reminder_body,
} => {
let counter = if fell_back {
COUNTER_FELL_BACK
} else {
COUNTER_SUGGESTED
};
bump(
counter,
counter_attrs(&config.persona, original_tool, *target),
);
Some(reminder_body.clone())
}
}
}
pub(crate) fn options_to_json(options: &BTreeMap<String, crate::value::VmValue>) -> JsonValue {
JsonValue::Object(
options
.iter()
.map(|(key, value)| (key.clone(), crate::llm::helpers::vm_value_to_json(value)))
.collect(),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::observability::vocabulary;
use serde_json::json;
fn cfg(mode: CompassMode) -> CompassConfig {
CompassConfig {
mode,
persona: "fixer".to_string(),
prefer: Vec::new(),
}
}
#[test]
fn off_mode_always_passes_through() {
let args = json!({"path": "src/lib.rs", "old_text": "a", "new_text": "b"});
assert_eq!(
route("str_replace", &args, &cfg(CompassMode::Off)),
CompassDecision::Passthrough
);
}
#[test]
fn structural_calls_are_never_rerouted() {
let args = json!({"path": "src/lib.rs", "query": "(x)", "replacement": "{}"});
assert_eq!(
route("edit_apply_node", &args, &cfg(CompassMode::Suggest)),
CompassDecision::Passthrough
);
assert_eq!(
route("edit_rename_symbol", &args, &cfg(CompassMode::Rewrite)),
CompassDecision::Passthrough
);
}
#[test]
fn non_parseable_file_passes_through() {
let args = json!({"path": "README.md", "old_text": "a", "new_text": "b"});
assert_eq!(
route("str_replace", &args, &cfg(CompassMode::Suggest)),
CompassDecision::Passthrough
);
}
#[test]
fn freeform_replace_on_source_file_suggests_in_suggest_mode() {
let args =
json!({"path": "src/lib.rs", "old_text": "let a = 1;", "new_text": "let a = 2;"});
match route("str_replace", &args, &cfg(CompassMode::Suggest)) {
CompassDecision::Suggest {
target,
reminder_body,
} => {
assert_eq!(target, StructuralTarget::SafeTextPatch);
assert!(reminder_body.contains("edit_safe_text_patch"));
assert!(reminder_body.contains("compass"));
}
other => panic!("expected Suggest, got {other:?}"),
}
}
#[test]
fn single_token_rename_suggests_rename_symbol() {
let args = json!({"path": "src/lib.rs", "old_text": "Widget", "new_text": "Gadget"});
match route("str_replace", &args, &cfg(CompassMode::Suggest)) {
CompassDecision::Suggest {
target,
reminder_body,
} => {
assert_eq!(target, StructuralTarget::RenameSymbol);
assert!(reminder_body.contains("edit_rename_symbol"));
assert!(reminder_body.contains("Widget"));
assert!(reminder_body.contains("Gadget"));
}
other => panic!("expected rename Suggest, got {other:?}"),
}
}
#[test]
fn rewrite_mode_substitutes_provably_equivalent_safe_patch() {
let args = json!({"path": "src/lib.rs", "old_text": "let a = 1;", "new_text": "let a = 2;", "session_id": "s1"});
match route("str_replace", &args, &cfg(CompassMode::Rewrite)) {
CompassDecision::Rewrite {
target,
tool_name,
tool_args,
} => {
assert_eq!(target, StructuralTarget::SafeTextPatch);
assert_eq!(tool_name, "edit_safe_text_patch");
assert_eq!(tool_args["path"], json!("src/lib.rs"));
assert_eq!(tool_args["hunks"][0]["old_text"], json!("let a = 1;"));
assert_eq!(tool_args["hunks"][0]["new_text"], json!("let a = 2;"));
assert_eq!(tool_args["session_id"], json!("s1"));
}
other => panic!("expected Rewrite, got {other:?}"),
}
}
#[test]
fn rewrite_mode_falls_back_for_non_equivalent_rename() {
let args = json!({"path": "src/lib.rs", "old_text": "Widget", "new_text": "Gadget"});
match route("str_replace", &args, &cfg(CompassMode::Rewrite)) {
CompassDecision::Suggest { target, .. } => {
assert_eq!(target, StructuralTarget::RenameSymbol);
}
other => panic!("expected fallback Suggest, got {other:?}"),
}
}
#[test]
fn whole_file_write_suggests_apply_node() {
let args = json!({"path": "src/lib.rs", "content": "fn main() {}"});
match route("write_file", &args, &cfg(CompassMode::Suggest)) {
CompassDecision::Suggest {
target,
reminder_body,
} => {
assert_eq!(target, StructuralTarget::ApplyNode);
assert!(reminder_body.contains("edit_apply_node"));
}
other => panic!("expected Suggest for whole-file write, got {other:?}"),
}
assert!(matches!(
route("write_file", &args, &cfg(CompassMode::Rewrite)),
CompassDecision::Suggest { .. }
));
}
#[test]
fn config_defaults_to_suggest_when_compass_absent() {
let config = CompassConfig::from_options(&json!({}));
assert_eq!(config.mode, CompassMode::Suggest);
assert_eq!(config.persona, "default");
}
#[test]
fn config_disables_on_false_and_enabled_false() {
assert_eq!(
CompassConfig::from_options(&json!({"compass": false})).mode,
CompassMode::Off
);
assert_eq!(
CompassConfig::from_options(&json!({"compass": {"enabled": false}})).mode,
CompassMode::Off
);
assert_eq!(
CompassConfig::from_options(&json!({"compass": {"mode": "off"}})).mode,
CompassMode::Off
);
}
#[test]
fn config_reads_mode_persona_and_prefer() {
let config = CompassConfig::from_options(&json!({
"persona": "fixer",
"compass": {"mode": "rewrite", "prefer": ["edit_rename_symbol"]},
}));
assert_eq!(config.mode, CompassMode::Rewrite);
assert_eq!(config.persona, "fixer");
assert_eq!(config.prefer, vec!["edit_rename_symbol".to_string()]);
}
#[test]
fn config_falls_back_to_edit_strategy_prefer_signal() {
let config = CompassConfig::from_options(&json!({
"edit_strategy": {"prefer": ["edit_apply_node", "edit_insert_at_anchor"]},
}));
assert_eq!(
config.prefer,
vec![
"edit_apply_node".to_string(),
"edit_insert_at_anchor".to_string()
]
);
}
#[test]
fn apply_decision_counts_suggested_and_returns_body() {
let decision = CompassDecision::Suggest {
target: StructuralTarget::SafeTextPatch,
reminder_body: "body".to_string(),
};
let body = apply_decision(&decision, "str_replace", &cfg(CompassMode::Suggest), false);
assert_eq!(body.as_deref(), Some("body"));
}
#[test]
fn apply_decision_rewrite_is_silent() {
let decision = CompassDecision::Rewrite {
target: StructuralTarget::SafeTextPatch,
tool_name: "edit_safe_text_patch".to_string(),
tool_args: json!({}),
};
let body = apply_decision(&decision, "str_replace", &cfg(CompassMode::Rewrite), false);
assert_eq!(body, None);
}
#[test]
fn apply_decision_increments_compass_counter() {
use crate::stdlib::observability;
observability::reset_observability_state();
observability::install_default_backend("test").expect("install test backend");
let decision = CompassDecision::Suggest {
target: StructuralTarget::SafeTextPatch,
reminder_body: "body".to_string(),
};
let _ = apply_decision(&decision, "str_replace", &cfg(CompassMode::Suggest), false);
let emissions = observability::captured_emissions();
let blob = serde_json::to_string(&emissions).unwrap();
assert!(
blob.contains("harn.compass.suggested"),
"expected a harn.compass.suggested counter, got: {blob}"
);
assert!(
blob.contains("str_replace"),
"expected the freeform tool tag"
);
observability::reset_observability_state();
}
#[test]
fn apply_decision_fell_back_uses_fell_back_counter() {
use crate::stdlib::observability;
observability::reset_observability_state();
observability::install_default_backend("test").expect("install test backend");
let decision = CompassDecision::Suggest {
target: StructuralTarget::RenameSymbol,
reminder_body: "body".to_string(),
};
let _ = apply_decision(&decision, "str_replace", &cfg(CompassMode::Rewrite), true);
let blob = serde_json::to_string(&observability::captured_emissions()).unwrap();
assert!(
blob.contains("harn.compass.fell_back"),
"expected harn.compass.fell_back counter, got: {blob}"
);
observability::reset_observability_state();
}
#[test]
fn counter_attrs_use_compass_vocabulary() {
let attrs = counter_attrs("fixer", "str_replace", StructuralTarget::SafeTextPatch);
for key in attrs.keys() {
assert!(
!vocabulary::is_violation(key),
"attr `{key}` must be in the compass vocabulary"
);
}
}
}