Skip to main content

ito_common/
harness.rs

1//! Harness session environment detection shared across Ito crates.
2
3/// Ito harness identifiers inferred from session environment variables.
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum HarnessKind {
6    /// Generic Ito harness session override.
7    Generic,
8    /// Claude Code harness.
9    ClaudeCode,
10    /// OpenCode harness.
11    OpenCode,
12    /// Codex harness.
13    Codex,
14    /// GitHub Copilot harness.
15    GitHubCopilot,
16    /// Pi coding agent harness.
17    Pi,
18}
19
20impl HarnessKind {
21    /// Return the canonical harness identifier used in generated Ito assets.
22    pub fn canonical_name(self) -> &'static str {
23        match self {
24            Self::Generic => "generic",
25            Self::ClaudeCode => "claude-code",
26            Self::OpenCode => "opencode",
27            Self::Codex => "codex",
28            Self::GitHubCopilot => "github-copilot",
29            Self::Pi => "pi",
30        }
31    }
32}
33
34/// Resolved harness session environment.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct HarnessSessionEnv {
37    /// Environment variable that supplied the session id.
38    pub env_var: &'static str,
39    /// Session id value from the environment.
40    pub session_id: String,
41    /// Harness kind inferred from the environment variable.
42    pub harness: HarnessKind,
43}
44
45#[derive(Debug, Clone, Copy)]
46struct HarnessSessionProbe {
47    env_var: &'static str,
48    harness: HarnessKind,
49}
50
51const HARNESS_SESSION_PROBES: &[HarnessSessionProbe] = &[
52    HarnessSessionProbe {
53        env_var: "ITO_HARNESS_SESSION_ID",
54        harness: HarnessKind::Generic,
55    },
56    HarnessSessionProbe {
57        env_var: "CLAUDE_SESSION_ID",
58        harness: HarnessKind::ClaudeCode,
59    },
60    HarnessSessionProbe {
61        env_var: "OPENCODE_SESSION_ID",
62        harness: HarnessKind::OpenCode,
63    },
64    HarnessSessionProbe {
65        env_var: "CODEX_SESSION_ID",
66        harness: HarnessKind::Codex,
67    },
68    HarnessSessionProbe {
69        env_var: "GITHUB_COPILOT_CHAT_SESSION_ID",
70        harness: HarnessKind::GitHubCopilot,
71    },
72    HarnessSessionProbe {
73        env_var: "PI_SESSION_ID",
74        harness: HarnessKind::Pi,
75    },
76];
77
78/// Environment variables that Ito checks for harness session context, in precedence order.
79pub const HARNESS_SESSION_ENV_VARS: &[&str] = &[
80    "ITO_HARNESS_SESSION_ID",
81    "CLAUDE_SESSION_ID",
82    "OPENCODE_SESSION_ID",
83    "CODEX_SESSION_ID",
84    "GITHUB_COPILOT_CHAT_SESSION_ID",
85    "PI_SESSION_ID",
86];
87
88/// Resolve harness session environment from the current process.
89pub fn resolve_harness_session_env() -> Option<HarnessSessionEnv> {
90    resolve_harness_session_env_with(|env_var| std::env::var(env_var).ok())
91}
92
93fn resolve_harness_session_env_with(
94    mut get_env: impl FnMut(&str) -> Option<String>,
95) -> Option<HarnessSessionEnv> {
96    for HarnessSessionProbe { env_var, harness } in HARNESS_SESSION_PROBES {
97        let Some(session_id) = get_env(env_var) else {
98            continue;
99        };
100        let session_id = session_id.trim();
101        if session_id.is_empty() {
102            continue;
103        }
104        let session_id = session_id.to_owned();
105        return Some(HarnessSessionEnv {
106            env_var,
107            session_id,
108            harness: *harness,
109        });
110    }
111
112    None
113}
114
115/// Resolve the first configured harness session id from the current process.
116pub fn resolve_harness_session_id() -> Option<String> {
117    resolve_harness_session_env().map(|env| env.session_id)
118}
119
120/// Detect the current harness canonical name from session environment variables.
121pub fn detect_harness_name() -> &'static str {
122    resolve_harness_session_env()
123        .map(|env| env.harness.canonical_name())
124        .unwrap_or("unknown")
125}
126
127#[cfg(test)]
128mod tests {
129    use super::{
130        HARNESS_SESSION_ENV_VARS, HARNESS_SESSION_PROBES, HarnessSessionEnv,
131        resolve_harness_session_env_with,
132    };
133
134    fn resolve_from(values: &[(&str, &str)]) -> Option<HarnessSessionEnv> {
135        resolve_harness_session_env_with(|env_var| {
136            values
137                .iter()
138                .find(|(key, _value)| *key == env_var)
139                .map(|(_key, value)| (*value).to_string())
140        })
141    }
142
143    #[test]
144    fn detects_canonical_harness_names() {
145        let cases = [
146            ("ITO_HARNESS_SESSION_ID", "generic"),
147            ("CLAUDE_SESSION_ID", "claude-code"),
148            ("OPENCODE_SESSION_ID", "opencode"),
149            ("CODEX_SESSION_ID", "codex"),
150            ("GITHUB_COPILOT_CHAT_SESSION_ID", "github-copilot"),
151            ("PI_SESSION_ID", "pi"),
152        ];
153
154        for (env_var, expected) in cases {
155            let env = resolve_from(&[(env_var, "test-session")]).expect("harness env");
156            assert_eq!(env.harness.canonical_name(), expected);
157            assert_eq!(env.env_var, env_var);
158        }
159    }
160
161    #[test]
162    fn generic_session_id_takes_precedence() {
163        let env = resolve_from(&[
164            ("OPENCODE_SESSION_ID", "opencode-session"),
165            ("ITO_HARNESS_SESSION_ID", "generic-session"),
166        ])
167        .expect("harness env");
168        assert_eq!(env.env_var, "ITO_HARNESS_SESSION_ID");
169        assert_eq!(env.session_id, "generic-session");
170        assert_eq!(env.harness.canonical_name(), "generic");
171    }
172
173    #[test]
174    fn returns_unknown_without_harness_env() {
175        assert_eq!(resolve_from(&[]), None);
176    }
177
178    #[test]
179    fn public_env_var_list_matches_probe_order() {
180        let probe_env_vars: Vec<&str> = HARNESS_SESSION_PROBES
181            .iter()
182            .map(|probe| probe.env_var)
183            .collect();
184        assert_eq!(probe_env_vars, HARNESS_SESSION_ENV_VARS);
185    }
186}