use std::cmp::Ordering;
use serde_yaml::Value;
use crate::HarnessId;
use crate::agent::LoweredField;
use crate::diagnostic::{PathSegment, SchemaDiagnostic, Severity};
use crate::frontmatter::Frontmatter;
use crate::harness_overrides::{Moved, move_keys_under};
use crate::item::DefinitionKind;
use crate::skill;
const KEPT_KEYS: &[&str] = &["name", "description", "harness-overrides"];
pub(crate) const RESTRICTION_LOST: &str = "field.restriction-lost";
const RESTRICTION_KEYS: &[&str] = &[
"permission",
"sandbox",
"approval",
"network",
"network-deny",
"tools",
"disallowed-tools",
];
const HOST_SET_KEYS: &[&str] = &["network-allow", "network-deny"];
#[derive(Clone, Copy)]
pub(crate) enum Destination<'a> {
Overrides(&'a str),
Nowhere,
}
impl Destination<'_> {
pub(crate) fn for_dialect(dialect: crate::dialect::Dialect) -> Destination<'static> {
use crate::dialect::Dialect;
match dialect {
Dialect::Claude => Destination::Overrides(HarnessId::Claude.as_str()),
Dialect::Codex => Destination::Overrides(HarnessId::Codex.as_str()),
Dialect::OpenCode => Destination::Overrides(HarnessId::OpenCode.as_str()),
Dialect::Cursor => Destination::Overrides(HarnessId::Cursor.as_str()),
Dialect::AgentSkills | Dialect::MarsNative => Destination::Nowhere,
}
}
}
struct Rejection {
path: Vec<PathSegment>,
why: String,
}
fn rejected_keys(kind: DefinitionKind, fm: &Frontmatter) -> Vec<(String, Vec<Rejection>)> {
let errors = crate::read::field_diagnostics(kind, fm)
.into_iter()
.filter(|diagnostic| {
diagnostic.is_error()
&& diagnostic.code != "field.required"
&& !diagnostic.code.starts_with("yaml.")
})
.filter(|diagnostic| matches!(diagnostic.path.first(), Some(PathSegment::Key(_))))
.collect::<Vec<_>>();
fm.keys()
.into_iter()
.filter(|key| !KEPT_KEYS.contains(&key.as_str()))
.filter_map(|key| {
let own = errors
.iter()
.filter(|diagnostic| matches!(diagnostic.path.first(), Some(PathSegment::Key(first)) if *first == key))
.map(|diagnostic| Rejection {
path: diagnostic.path.clone(),
why: diagnostic.message.clone(),
})
.collect::<Vec<_>>();
(!own.is_empty()).then_some((key, own))
})
.collect()
}
fn path_text(path: &[PathSegment]) -> String {
path.iter()
.map(|segment| match segment {
PathSegment::Key(key) => key.clone(),
PathSegment::Index(index) => index.to_string(),
})
.collect::<Vec<_>>()
.join(".")
}
fn compare_paths(a: &[PathSegment], b: &[PathSegment]) -> Ordering {
for (left, right) in a.iter().zip(b) {
let order = match (left, right) {
(PathSegment::Key(left), PathSegment::Key(right)) => left.cmp(right),
(PathSegment::Index(left), PathSegment::Index(right)) => left.cmp(right),
(PathSegment::Key(_), PathSegment::Index(_)) => Ordering::Less,
(PathSegment::Index(_), PathSegment::Key(_)) => Ordering::Greater,
};
if order != Ordering::Equal {
return order;
}
}
a.len().cmp(&b.len())
}
fn remove_at(value: &mut Value, path: &[PathSegment]) -> Option<Value> {
match path {
[] => None,
[last] => match (value, last) {
(Value::Mapping(mapping), PathSegment::Key(key)) => mapping.remove(key.as_str()),
(Value::Sequence(sequence), PathSegment::Index(index)) if *index < sequence.len() => {
Some(sequence.remove(*index))
}
_ => None,
},
[first, rest @ ..] => {
let child = match (value, first) {
(Value::Mapping(mapping), PathSegment::Key(key)) => mapping.get_mut(key.as_str()),
(Value::Sequence(sequence), PathSegment::Index(index)) => sequence.get_mut(*index),
_ => None,
}?;
remove_at(child, rest)
}
}
}
fn drop_entries(
fm: &mut Frontmatter,
key: &str,
rejections: &[Rejection],
) -> Option<Vec<(Vec<PathSegment>, Value)>> {
let mut value = fm.get(key)?.clone();
if !matches!(value, Value::Mapping(_) | Value::Sequence(_)) {
return None;
}
let mut paths = rejections
.iter()
.map(|rejection| rejection.path.clone())
.collect::<Vec<_>>();
paths.sort_by(|a, b| compare_paths(b, a));
paths.dedup();
let mut removed = Vec::new();
for path in paths {
if path.len() < 2 {
return None;
}
let entry = remove_at(&mut value, &path[1..])?;
removed.push((path, entry));
}
let emptied = match &value {
Value::Mapping(mapping) => mapping.is_empty(),
Value::Sequence(sequence) => sequence.is_empty(),
_ => false,
};
if emptied {
fm.remove(key);
} else {
fm.insert(key, value);
}
Some(removed)
}
fn restriction_lost(path: &[PathSegment], removed: &Value) -> Option<String> {
let Some(PathSegment::Key(key)) = path.first() else {
return None;
};
if path.len() == 1 {
return RESTRICTION_KEYS
.contains(&key.as_str())
.then(|| format!("`{key}`"));
}
let restricting = match key.as_str() {
"tools" => {
matches!(removed.as_str(), Some("deny" | "ask")) || *removed == Value::Bool(false)
}
"disallowed-tools" | "network-deny" => true,
_ => false,
};
let value = match removed {
Value::String(text) => text.clone(),
other => crate::ordered::plain(other),
};
restricting.then(|| match key.as_str() {
"tools" => format!("`{}: {value}`", path_text(path)),
"network-deny" => format!("`network-deny: {value}`"),
_ => format!("`{}`", path_text(path)),
})
}
fn reason(key: &str, value: Option<&Value>, destination: Destination<'_>, read: &str) -> String {
if key != "permission" || !matches!(value, Some(Value::Mapping(_))) {
return read.to_owned();
}
match destination {
Destination::Overrides(_) => "`permission` as a map is OpenCode's format; declare \
`dialect = \"open-code\"` on this dependency so Mars reads it"
.to_owned(),
Destination::Nowhere => "`permission` as a map is OpenCode's format; put it under \
`harness-overrides.opencode.permission`"
.to_owned(),
}
}
struct Report<'a> {
agent: String,
changes: &'a mut Vec<SchemaDiagnostic>,
}
impl Report<'_> {
fn note(&mut self, path: &[PathSegment], lost: Option<String>, what: &str, why: &str) {
let shown = path_text(path);
let (code, message) = match lost {
Some(lost) => (
RESTRICTION_LOST,
format!(
"{} lost the restriction {lost}: {what}. Schema v1 does not accept it ({why})",
self.agent
),
),
None => (
"field.set-aside",
format!("`{shown}` is not valid in schema v1 ({why}); {what}"),
),
};
self.changes.push(SchemaDiagnostic::new(
code,
Severity::Warning,
path.to_vec(),
message,
));
}
}
pub(crate) fn set_aside_rejected_keys(
fm: &mut Frontmatter,
kind: DefinitionKind,
destination: Destination<'_>,
changes: &mut Vec<SchemaDiagnostic>,
) -> bool {
let rejected = rejected_keys(kind, fm);
if rejected.is_empty() {
return false;
}
let mut report = Report {
agent: match fm.get("name").and_then(Value::as_str) {
Some(name) => format!("agent `{name}`"),
None => "this agent".to_owned(),
},
changes,
};
let mut changed = false;
let mut movable = Vec::new();
for (key, rejections) in rejected {
let reserved = match kind {
DefinitionKind::Agent => LoweredField::reserves(&key),
DefinitionKind::Skill => skill::LOWERED_KEYS.contains(&key.as_str()),
};
let whole_key = rejections.iter().any(|rejection| rejection.path.len() == 1);
if (reserved || HOST_SET_KEYS.contains(&key.as_str()))
&& !whole_key
&& let Some(removed) = drop_entries(fm, &key, &rejections)
{
changed = true;
for (path, entry) in removed {
let why = rejections
.iter()
.find(|rejection| rejection.path == path)
.map_or("", |rejection| rejection.why.as_str());
let lost = restriction_lost(&path, &entry);
let what = format!("that entry was dropped and the rest of `{key}` is kept");
report.note(&path, lost, &what, why);
}
continue;
}
let why = reason(&key, fm.get(&key), destination, &rejections[0].why);
if reserved || matches!(destination, Destination::Nowhere) {
let removed = fm.remove(&key).unwrap_or(Value::Null);
changed = true;
let path = vec![PathSegment::Key(key.clone())];
let lost = restriction_lost(&path, &removed);
let what = if reserved {
format!(
"it was dropped, because Mars lowers `{key}` itself and an override would not carry it"
)
} else {
"it was dropped, because no harness reads it".to_owned()
};
report.note(&path, lost, &what, &why);
} else {
movable.push((key, why));
}
}
if let Destination::Overrides(harness) = destination
&& !movable.is_empty()
{
let keys = movable
.iter()
.map(|(key, _)| (key.as_str(), key.as_str()))
.collect::<Vec<_>>();
if let Moved::Done { moved, displaced } = move_keys_under(fm, harness, &keys) {
changed = true;
for (key, why) in &movable {
let what = if moved.contains(&key.as_str()) {
format!(
"it was moved under `harness-overrides.{harness}`, which Mars does not enforce"
)
} else if displaced.iter().any(|(from, _)| *from == key.as_str()) {
format!("it was dropped, `harness-overrides.{harness}.{key}` already sets it")
} else {
continue;
};
let path = vec![PathSegment::Key(key.clone())];
let lost = RESTRICTION_KEYS
.contains(&key.as_str())
.then(|| format!("`{key}`"));
report.note(&path, lost, &what, why);
}
}
}
changed
}