Skip to main content

mars_schema/
upgrade.rs

1//! Pure legacy Mars definition and manifest upgrades to schema v1.
2
3use 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    /// What the upgrade changed, one note per key it dropped or moved.
19    pub diagnostics: Vec<SchemaDiagnostic>,
20    /// A changed definition whose source shape prevented top-level block editing.
21    pub fully_rewritten: bool,
22    /// Authored key paths whose values or presence changed.
23    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    // The fallback re-renders the frontmatter only. The body is authored prompt text that may be
62    // byte-sensitive (embedded examples, mixed line endings), so it is copied from the input
63    // rather than from the render, which ends every line in LF.
64    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
84/// Convert a `mars.toml` to schema v1.
85///
86/// `prepare` and `mars migrate` run this on a pre-v1 package. It never touches `requires-mars`:
87/// the requirement gates whether a package may be read at all, so only `mars migrate`,
88/// which a human runs on the package they own, adds one (see [`ensure_requires_mars_floor`]).
89pub 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/// Adding the `requires-mars` floor to a manifest.
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct FloorOutcome {
121    /// The manifest with the key added, or the input when it was not.
122    pub text: String,
123    /// `mars check`'s advice, when the requirement exists and admits a Mars that reads the
124    /// package as pre-v1, or when the manifest has a shape migrate cannot edit minimally.
125    pub warning: Option<String>,
126}
127
128/// Add `requires-mars = ">=0.20.0"` to a manifest that has no requirement.
129///
130/// `mars migrate` runs this on the manifest it is about to write, so a schema v1 package needs
131/// no hand edit. An existing requirement is never rewritten: when it admits a Mars older than
132/// the first release that reads schema 1, [`FloorOutcome::warning`] is `mars check`'s advice and
133/// the value keeps its bytes and comment. A manifest migrate cannot edit minimally (an inline
134/// `[package]` table) comes back unchanged with the same advice, never an error, so an
135/// already-v1 package migrates exactly as it did before this key existed.
136pub 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
171/// Whether the file's first line ends in `\r\n`.
172fn is_crlf(text: &str) -> bool {
173    text.find('\n')
174        .is_some_and(|end| text.as_bytes()[..end].ends_with(b"\r"))
175}
176
177/// `written` uses `\n` where the upgrade added lines; a CRLF file stays CRLF.
178fn 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
219/// Rewrite only changed top-level blocks. Complex top-level YAML deliberately returns `None` so
220/// the caller has one explicit, verified full-serialization fallback.
221fn 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
351/// The rules for what only pre-v1 Mars wrote, key by key: the invocability renames, the
352/// discarded Agent fields, the removed values, a scalar `permission` and the removed model-policy
353/// override keys. No foreign dialect defines any of them, so every translation applies them
354/// before its own rules (`lift_frontmatter`), and the legacy upgrade is the same rules plus its
355/// own. A tree is never classified as pre-v1 Mars by finding one: its other keys are read in the
356/// dialect they belong to.
357pub(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    // A pre-v1 file passed unknown keys to every harness; Claude's own keep reaching Claude.
386    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
439/// What the rules above could not make schema v1 is dropped, with a warning naming it, so the
440/// upgrade never hands the strict reader a value it rejects. It is not kept under
441/// `harness-overrides.flow`: Flow defines one key, `permission`, and `lift_mars_only_keys` writes
442/// it there when it is `read` or `edit`. Anything else in that namespace would not be Flow's.
443fn 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        // The legacy effort spellings mean the same here as at the top level.
531        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
564/// A host set written under both spellings keeps every host of both: dropping either would
565/// lose a deny. A value that is not a host list or a `{replace: [...]}` map stays where it is, so
566/// the reader reports it.
567fn 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}