Skip to main content

mars_schema/
harness.rs

1//! Stable harness identifiers and authored-name parsing.
2
3use std::fmt;
4
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
8#[serde(rename_all = "snake_case")]
9pub enum HarnessId {
10    Claude,
11    Codex,
12    Pi,
13    #[serde(rename = "opencode")]
14    OpenCode,
15    Cursor,
16}
17
18impl HarnessId {
19    pub const fn as_str(self) -> &'static str {
20        match self {
21            Self::Claude => "claude",
22            Self::Codex => "codex",
23            Self::Pi => "pi",
24            Self::OpenCode => "opencode",
25            Self::Cursor => "cursor",
26        }
27    }
28}
29
30impl fmt::Display for HarnessId {
31    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
32        f.write_str(self.as_str())
33    }
34}
35
36pub const ALL: &[HarnessId] = &[
37    HarnessId::Claude,
38    HarnessId::Codex,
39    HarnessId::Pi,
40    HarnessId::OpenCode,
41    HarnessId::Cursor,
42];
43
44/// The harness names in `ALL`'s order, as error messages list them.
45pub fn names() -> String {
46    ALL.iter()
47        .map(|harness| harness.as_str())
48        .collect::<Vec<_>>()
49        .join(", ")
50}
51
52/// Consumer namespaces under `harness-overrides` that are not harnesses: the consumer
53/// owns the block's contents, and Mars only keeps it. Each is the host's full name: Meridian
54/// Flow's is `meridian-flow`, the Meridian CLI's is `meridian-cli`.
55pub(crate) const RESERVED_CONSUMER_NAMESPACES: &[&str] = &["meridian-flow", "meridian-cli"];
56
57/// The namespace spellings before the full host names; the reader reports them as removed and
58/// `mars migrate` rewrites them to the name beside them.
59pub(crate) const RENAMED_CONSUMER_NAMESPACES: &[(&str, &str)] =
60    &[("flow", "meridian-flow"), ("meridian", "meridian-cli")];
61
62pub fn parse(name: &str) -> Option<HarnessId> {
63    let normalized = name.trim().to_ascii_lowercase();
64    ALL.iter()
65        .copied()
66        .find(|harness| harness.as_str() == normalized)
67}
68
69pub fn normalize_name(name: &str) -> Option<String> {
70    parse(name).map(|id| id.as_str().to_owned())
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76
77    #[test]
78    fn parse_and_normalize_are_case_insensitive() {
79        assert_eq!(parse("OpenCode"), Some(HarnessId::OpenCode));
80        assert_eq!(normalize_name(" OpenCode "), Some("opencode".to_string()));
81        assert_eq!(parse("gemini"), None);
82    }
83
84    #[test]
85    fn serde_names_round_trip_registry_names() {
86        for &harness in ALL {
87            let json = serde_json::to_string(&harness).unwrap();
88            assert_eq!(json, format!("\"{}\"", harness.as_str()));
89            assert_eq!(serde_json::from_str::<HarnessId>(&json).unwrap(), harness);
90        }
91        assert!(serde_json::from_str::<HarnessId>("\"open_code\"").is_err());
92    }
93}