1use serde_yaml::{Mapping, Value};
4use toml_edit::{DocumentMut, Item};
5
6use crate::claude;
7use crate::diagnostic::{PathSegment, SchemaDiagnostic, Severity};
8use crate::frontmatter::{Frontmatter, FrontmatterError};
9use crate::item::DefinitionKind;
10use crate::{
11 agent::{REMOVED_AGENT_FIELDS, REMOVED_AGENT_VALUES},
12 skill,
13};
14
15#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
16pub struct UpgradeResult {
17 pub text: String,
18 pub diagnostics: Vec<SchemaDiagnostic>,
20 pub fully_rewritten: bool,
22 pub edited_paths: Vec<Vec<PathSegment>>,
24}
25
26impl UpgradeResult {
27 pub(crate) fn changed_from(&self, input: &str) -> bool {
28 self.text != input
29 }
30}
31
32#[derive(Debug, thiserror::Error)]
33pub enum UpgradeError {
34 #[error(transparent)]
35 Frontmatter(#[from] FrontmatterError),
36 #[error("failed to parse mars.toml: {0}")]
37 Manifest(#[from] toml_edit::TomlError),
38 #[error("mars.toml at a package root must contain [package]")]
39 MissingPackage,
40}
41
42pub fn upgrade_definition(kind: DefinitionKind, text: &str) -> Result<UpgradeResult, UpgradeError> {
43 let original = Frontmatter::parse(text)?;
44 let mut frontmatter = original.clone();
45 let mut diagnostics = Vec::new();
46 match kind {
47 DefinitionKind::Agent => upgrade_agent(&mut frontmatter, &mut diagnostics),
48 DefinitionKind::Skill => upgrade_skill(&mut frontmatter, &mut diagnostics),
49 }
50 if frontmatter.mapping() == original.mapping() {
51 return Ok(UpgradeResult {
52 text: text.to_string(),
53 diagnostics,
54 fully_rewritten: false,
55 edited_paths: Vec::new(),
56 });
57 }
58
59 let edited_paths = changed_mapping_paths(original.mapping(), frontmatter.mapping());
60
61 let fallback = {
65 let mut rewritten = with_line_ending_of(text, frontmatter.render_frontmatter());
66 rewritten.push_str(original.body());
67 rewritten
68 };
69 let minimally_edited = edit_top_level_blocks(text, original.mapping(), frontmatter.mapping())
70 .and_then(|edited| {
71 Frontmatter::parse(&edited)
72 .ok()
73 .filter(|parsed| parsed.mapping() == frontmatter.mapping())
74 .map(|_| edited)
75 });
76 Ok(UpgradeResult {
77 fully_rewritten: minimally_edited.is_none(),
78 text: minimally_edited.unwrap_or(fallback),
79 diagnostics,
80 edited_paths,
81 })
82}
83
84pub fn upgrade_manifest(text: &str) -> Result<UpgradeResult, UpgradeError> {
90 let mut document = text.parse::<DocumentMut>()?;
91 let package = document
92 .get_mut("package")
93 .and_then(Item::as_table_mut)
94 .ok_or(UpgradeError::MissingPackage)?;
95 if package.get("schema").and_then(Item::as_integer) == Some(1) {
96 return Ok(UpgradeResult {
97 text: text.to_string(),
98 diagnostics: Vec::new(),
99 fully_rewritten: false,
100 edited_paths: Vec::new(),
101 });
102 }
103 let mut diagnostics = Vec::new();
104 let mut edited_paths = vec![package_path("schema")];
105 package.insert("schema", toml_edit::value(1));
106 if package.remove("visibility").is_some() {
107 diagnostics.push(removed(&["package", "visibility"], "visibility"));
108 edited_paths.push(package_path("visibility"));
109 }
110 Ok(UpgradeResult {
111 text: with_line_ending_of(text, document.to_string()),
112 diagnostics,
113 fully_rewritten: false,
114 edited_paths,
115 })
116}
117
118#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct FloorOutcome {
121 pub text: String,
123 pub warning: Option<String>,
126}
127
128pub fn ensure_requires_mars_floor(text: &str) -> Result<FloorOutcome, UpgradeError> {
137 let mut document = text.parse::<DocumentMut>()?;
138 let package = document
139 .get_mut("package")
140 .ok_or(UpgradeError::MissingPackage)?;
141 let requirement = package
142 .as_table_like()
143 .and_then(|table| table.get("requires-mars"))
144 .and_then(Item::as_str)
145 .map(str::to_owned);
146 if requirement.is_none()
147 && let Some(table) = package.as_table_mut()
148 {
149 table.insert(
150 "requires-mars",
151 toml_edit::value(format!(">={}", crate::package::SCHEMA_1_MARS_FLOOR)),
152 );
153 return Ok(FloorOutcome {
154 text: with_line_ending_of(text, document.to_string()),
155 warning: None,
156 });
157 }
158 Ok(FloorOutcome {
159 text: text.to_string(),
160 warning: crate::package::schema_1_requires_mars_warning(requirement.as_deref()),
161 })
162}
163
164fn package_path(key: &str) -> Vec<PathSegment> {
165 vec![
166 PathSegment::Key("package".to_string()),
167 PathSegment::Key(key.to_string()),
168 ]
169}
170
171fn is_crlf(text: &str) -> bool {
173 text.find('\n')
174 .is_some_and(|end| text.as_bytes()[..end].ends_with(b"\r"))
175}
176
177fn with_line_ending_of(original: &str, written: String) -> String {
179 if !is_crlf(original) {
180 return written;
181 }
182 let mut out = String::with_capacity(written.len() + written.len() / 16);
183 let mut previous = '\0';
184 for character in written.chars() {
185 if character == '\n' && previous != '\r' {
186 out.push('\r');
187 }
188 out.push(character);
189 previous = character;
190 }
191 out
192}
193
194fn changed_mapping_paths(before: &Mapping, after: &Mapping) -> Vec<Vec<PathSegment>> {
195 let mut keys = before
196 .keys()
197 .chain(after.keys())
198 .filter_map(Value::as_str)
199 .collect::<Vec<_>>();
200 keys.sort_unstable();
201 keys.dedup();
202 keys.into_iter()
203 .filter(|key| {
204 let key = Value::String((*key).to_string());
205 before.get(&key) != after.get(&key)
206 })
207 .map(|key| vec![PathSegment::Key(key.to_string())])
208 .collect()
209}
210
211#[derive(Debug)]
212struct SourceBlock<'a> {
213 key: &'a str,
214 text: &'a str,
215 key_end: usize,
216 start: usize,
217}
218
219fn edit_top_level_blocks(text: &str, before: &Mapping, after: &Mapping) -> Option<String> {
222 let (prefix, yaml, suffix) = frontmatter_source(text)?;
223 let blocks = source_blocks(yaml)?;
224 let before_keys = before
225 .keys()
226 .map(Value::as_str)
227 .collect::<Option<Vec<_>>>()?;
228 if blocks.iter().map(|block| block.key).collect::<Vec<_>>() != before_keys {
229 return None;
230 }
231
232 let render = |key: &str, value: &Value| with_line_ending_of(text, render_block(key, value));
233 let mut output = String::with_capacity(text.len());
234 output.push_str(prefix);
235 let first_key_offset = blocks.first().map_or(yaml.len(), |block| block.start);
236 output.push_str(&yaml[..first_key_offset]);
237
238 let mut emitted = Vec::new();
239 for block in blocks {
240 let before_value = before.get(Value::String(block.key.to_string()))?;
241 if let Some(rename) = renamed_key(block.key, before, after) {
242 output.push_str(rename);
243 output.push_str(&block.text[block.key_end..]);
244 emitted.push(rename);
245 } else if let Some(after_value) = after.get(Value::String(block.key.to_string())) {
246 if before_value == after_value {
247 output.push_str(block.text);
248 } else {
249 output.push_str(&render(block.key, after_value));
250 }
251 emitted.push(block.key);
252 }
253 }
254
255 for (key, value) in after {
256 let key = key.as_str()?;
257 if emitted.contains(&key) {
258 continue;
259 }
260 if !output.ends_with('\n') {
261 output.push_str(&with_line_ending_of(text, "\n".to_owned()));
262 }
263 output.push_str(&render(key, value));
264 }
265 output.push_str(suffix);
266 Some(output)
267}
268
269fn renamed_key<'a>(old: &str, before: &Mapping, after: &'a Mapping) -> Option<&'a str> {
270 let new = match old {
271 "model_invocable" => "model-invocable",
272 "user_invocable" => "user-invocable",
273 "disallowed_tools" => "disallowed-tools",
274 _ => return None,
275 };
276 if before.contains_key(Value::String(new.to_string())) {
277 return None;
278 }
279 let old_value = before.get(Value::String(old.to_string()))?;
280 (after.get(Value::String(new.to_string())) == Some(old_value)).then_some(new)
281}
282
283fn render_block(key: &str, value: &Value) -> String {
284 let mut mapping = Mapping::new();
285 mapping.insert(Value::String(key.to_string()), value.clone());
286 let yaml = serde_yaml::to_string(&mapping).expect("serializing an upgraded YAML block");
287 yaml.strip_prefix("---\n").unwrap_or(&yaml).to_string()
288}
289
290fn frontmatter_source(text: &str) -> Option<(&str, &str, &str)> {
291 let first_end = text.find('\n').map_or(text.len(), |offset| offset + 1);
292 if text[..first_end]
293 .strip_prefix('\u{feff}')
294 .unwrap_or(&text[..first_end])
295 .trim_end()
296 != "---"
297 {
298 return None;
299 }
300 let mut offset = first_end;
301 for line in text[first_end..].split_inclusive('\n') {
302 if line.trim_end() == "---" {
303 return Some((
304 &text[..first_end],
305 &text[first_end..offset],
306 &text[offset..],
307 ));
308 }
309 offset += line.len();
310 }
311 None
312}
313
314fn source_blocks(yaml: &str) -> Option<Vec<SourceBlock<'_>>> {
315 let mut starts = Vec::new();
316 let mut offset = 0;
317 for line in yaml.split_inclusive('\n') {
318 let content = line.trim_end_matches(['\r', '\n']);
319 if !content.is_empty()
320 && !content.starts_with(char::is_whitespace)
321 && !content.starts_with('#')
322 {
323 let colon = content.find(':')?;
324 let key = &content[..colon];
325 if key.is_empty()
326 || !key
327 .bytes()
328 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
329 {
330 return None;
331 }
332 starts.push((offset, key, colon));
333 }
334 offset += line.len();
335 }
336 let mut blocks = Vec::with_capacity(starts.len());
337 for (index, (start, key, key_end)) in starts.iter().copied().enumerate() {
338 let end = starts
339 .get(index + 1)
340 .map_or(yaml.len(), |(start, _, _)| *start);
341 blocks.push(SourceBlock {
342 key,
343 text: &yaml[start..end],
344 key_end,
345 start,
346 });
347 }
348 Some(blocks)
349}
350
351pub(crate) fn lift_mars_only_keys(
358 fm: &mut Frontmatter,
359 kind: DefinitionKind,
360 notes: &mut Vec<SchemaDiagnostic>,
361) -> bool {
362 let before = fm.mapping().clone();
363 rename_field(fm, "model_invocable", "model-invocable");
364 rename_field(fm, "user_invocable", "user-invocable");
365 if kind == DefinitionKind::Agent {
366 merge_host_set_spellings(fm, "network_allow", "network-allow");
367 merge_host_set_spellings(fm, "network_deny", "network-deny");
368 for field in REMOVED_AGENT_FIELDS.iter().filter(|field| field.discarded) {
369 if fm.remove(field.key).is_some() {
370 notes.push(removed(&[field.key], field.key));
371 }
372 }
373 for removed in REMOVED_AGENT_VALUES {
374 replace_scalar(fm, removed.key, removed.value, removed.replacement);
375 }
376 move_permission(fm);
377 strip_model_policy_overrides(fm, notes);
378 }
379 fm.mapping() != &before
380}
381
382fn upgrade_agent(fm: &mut Frontmatter, diagnostics: &mut Vec<SchemaDiagnostic>) {
383 upgrade_tools(fm);
384 lift_mars_only_keys(fm, DefinitionKind::Agent, diagnostics);
385 claude::translate::move_to_claude_overrides(fm, DefinitionKind::Agent, diagnostics);
387 set_aside_rejected_keys(fm, DefinitionKind::Agent, diagnostics);
388}
389
390fn upgrade_skill(fm: &mut Frontmatter, diagnostics: &mut Vec<SchemaDiagnostic>) {
391 lift_mars_only_keys(fm, DefinitionKind::Skill, diagnostics);
392
393 if let Some(skill_type) = fm.get("type").cloned() {
394 let metadata = fm
395 .get("metadata")
396 .cloned()
397 .unwrap_or_else(|| Value::Mapping(Mapping::new()));
398 if let Value::Mapping(mut metadata) = metadata {
399 let key = Value::String("type".to_string());
400 if !metadata.contains_key(&key) {
401 metadata.insert(key, skill_type);
402 fm.insert("metadata", Value::Mapping(metadata));
403 fm.remove("type");
404 }
405 }
406 }
407
408 if let Some(when) = fm
409 .get("when_to_use")
410 .and_then(Value::as_str)
411 .map(str::to_owned)
412 {
413 let description = match fm.get("description") {
414 Some(Value::String(description)) => Some(format!("{description}\n\n{when}")),
415 None => Some(when),
416 Some(_) => None,
417 };
418 if let Some(description) = description {
419 fm.insert("description", Value::String(description));
420 fm.remove("when_to_use");
421 }
422 }
423
424 for key in skill::tool_keys() {
425 if fm.remove(key).is_some() {
426 diagnostics.push(SchemaDiagnostic::new(
427 "skill.tools-dropped",
428 Severity::Warning,
429 vec![PathSegment::Key(key.to_string())],
430 format!("removed skill key `{key}`; skill tool rules have no v1 meaning"),
431 ));
432 }
433 }
434
435 claude::translate::move_to_claude_overrides(fm, DefinitionKind::Skill, diagnostics);
436 set_aside_rejected_keys(fm, DefinitionKind::Skill, diagnostics);
437}
438
439fn set_aside_rejected_keys(
444 fm: &mut Frontmatter,
445 kind: DefinitionKind,
446 diagnostics: &mut Vec<SchemaDiagnostic>,
447) {
448 crate::set_aside::set_aside_rejected_keys(
449 fm,
450 kind,
451 crate::set_aside::Destination::Nowhere,
452 diagnostics,
453 );
454}
455
456fn upgrade_tools(fm: &mut Frontmatter) {
457 rename_field(fm, "disallowed_tools", "disallowed-tools");
458 if let Some(Value::Sequence(entries)) = fm.get("tools").cloned()
459 && entries.iter().all(|entry| entry.as_str().is_some())
460 {
461 let policy = entries
462 .into_iter()
463 .map(|entry| (entry, Value::String("allow".to_string())))
464 .collect();
465 fm.insert("tools", Value::Mapping(policy));
466 }
467
468 let Some(Value::Sequence(disallowed)) = fm.get("disallowed-tools").cloned() else {
469 return;
470 };
471 if !disallowed.iter().all(|entry| entry.as_str().is_some()) {
472 return;
473 }
474 let policy = fm
475 .get("tools")
476 .cloned()
477 .unwrap_or_else(|| Value::Mapping(Mapping::new()));
478 let Value::Mapping(mut policy) = policy else {
479 return;
480 };
481 for entry in disallowed {
482 policy.insert(entry, Value::String("deny".to_string()));
483 }
484 fm.insert("tools", Value::Mapping(policy));
485 fm.remove("disallowed-tools");
486}
487
488fn move_permission(fm: &mut Frontmatter) {
489 let Some(permission @ ("read" | "edit")) = fm.get("permission").and_then(Value::as_str) else {
490 return;
491 };
492 let permission = permission.to_string();
493 let overrides = fm
494 .get("harness-overrides")
495 .cloned()
496 .unwrap_or_else(|| Value::Mapping(Mapping::new()));
497 let Value::Mapping(mut overrides) = overrides else {
498 return;
499 };
500 let flow_key = Value::String("flow".to_string());
501 let flow = overrides
502 .get(&flow_key)
503 .cloned()
504 .unwrap_or_else(|| Value::Mapping(Mapping::new()));
505 let Value::Mapping(mut flow) = flow else {
506 return;
507 };
508 let permission_key = Value::String("permission".to_string());
509 if flow.contains_key(&permission_key) {
510 return;
511 }
512 flow.insert(permission_key, Value::String(permission));
513 overrides.insert(flow_key, Value::Mapping(flow));
514 fm.insert("harness-overrides", Value::Mapping(overrides));
515 fm.remove("permission");
516}
517
518fn strip_model_policy_overrides(fm: &mut Frontmatter, diagnostics: &mut Vec<SchemaDiagnostic>) {
519 let Some(Value::Sequence(policies)) = fm.get_mut("model-policies") else {
520 return;
521 };
522 for (index, policy) in policies.iter_mut().enumerate() {
523 let Value::Mapping(policy) = policy else {
524 continue;
525 };
526 let Some(Value::Mapping(overrides)) = policy.get_mut(Value::String("override".to_string()))
527 else {
528 continue;
529 };
530 for (old, new) in [("adaptive", "medium"), ("disabled", "none")] {
532 let key = Value::String("effort".to_string());
533 if overrides.get(&key).and_then(Value::as_str) == Some(old) {
534 overrides.insert(key, Value::String(new.to_string()));
535 }
536 }
537 for key in ["autocompact", "autocompact_pct", "harness"] {
538 if overrides.remove(Value::String(key.to_string())).is_some() {
539 diagnostics.push(SchemaDiagnostic::new(
540 "field.removed",
541 Severity::Warning,
542 vec![
543 PathSegment::Key("model-policies".to_string()),
544 PathSegment::Index(index),
545 PathSegment::Key("override".to_string()),
546 PathSegment::Key(key.to_string()),
547 ],
548 format!("removed model policy override key `{key}`"),
549 ));
550 }
551 }
552 }
553}
554
555fn rename_field(fm: &mut Frontmatter, old: &str, new: &str) {
556 let Some(value) = fm.remove(old) else {
557 return;
558 };
559 if fm.get(new).is_none() {
560 fm.insert(new, value);
561 }
562}
563
564fn merge_host_set_spellings(fm: &mut Frontmatter, old: &str, new: &str) {
568 let (Some(old_value), Some(new_value)) = (fm.get(old), fm.get(new)) else {
569 return rename_field(fm, old, new);
570 };
571 let (Some((old_hosts, old_replaces)), Some((mut hosts, new_replaces))) =
572 (host_set_parts(old_value), host_set_parts(new_value))
573 else {
574 return;
575 };
576 for host in old_hosts {
577 if !hosts.contains(&host) {
578 hosts.push(host);
579 }
580 }
581 let merged = if old_replaces || new_replaces {
582 let mut replace = serde_yaml::Mapping::new();
583 replace.insert(Value::String("replace".to_string()), Value::Sequence(hosts));
584 Value::Mapping(replace)
585 } else {
586 Value::Sequence(hosts)
587 };
588 fm.insert(new, merged);
589 fm.remove(old);
590}
591
592fn host_set_parts(value: &Value) -> Option<(Vec<Value>, bool)> {
593 match value {
594 Value::Sequence(hosts) => Some((hosts.clone(), false)),
595 Value::Mapping(map) if map.len() == 1 => map
596 .get("replace")
597 .and_then(Value::as_sequence)
598 .map(|hosts| (hosts.clone(), true)),
599 _ => None,
600 }
601}
602
603fn replace_scalar(fm: &mut Frontmatter, key: &str, old: &str, new: &str) {
604 if fm.get(key).and_then(Value::as_str) == Some(old) {
605 fm.insert(key, Value::String(new.to_string()));
606 }
607}
608
609fn removed(path: &[&str], key: &str) -> SchemaDiagnostic {
610 SchemaDiagnostic::new(
611 "field.removed",
612 Severity::Warning,
613 path.iter()
614 .map(|part| PathSegment::Key((*part).to_string()))
615 .collect(),
616 format!("removed legacy key `{key}`"),
617 )
618}