Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 75 additions & 2 deletions codex-rs/external-agent-migration/src/rewrite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ fn replace_with_boundaries(input: &str, needle: &str, replacement: &str) -> Stri
let boundary_before = start == 0 || !is_word_byte(bytes[start - 1]);
let boundary_after = end == bytes.len() || !is_word_byte(bytes[end]);

if boundary_before && boundary_after {
if boundary_before && boundary_after && !is_literal_reference_match(bytes, start, end) {
output.push_str(&input[last_emitted..start]);
output.push_str(replacement);
last_emitted = end;
Expand Down Expand Up @@ -104,7 +104,7 @@ fn replace_case_insensitive_with_boundaries(
let boundary_before = start == 0 || !is_word_byte(bytes[start - 1]);
let boundary_after = end == bytes.len() || !is_word_byte(bytes[end]);

if boundary_before && boundary_after {
if boundary_before && boundary_after && !is_literal_reference_match(bytes, start, end) {
output.push_str(&input[last_emitted..start]);
output.push_str(replacement);
last_emitted = end;
Expand All @@ -121,6 +121,79 @@ fn replace_case_insensitive_with_boundaries(
output
}

/// Product names embedded in filesystem paths, URLs, or dotted identifiers are
/// literal references to the source tool, not prose that should be retargeted.
fn is_literal_reference_match(bytes: &[u8], start: usize, end: usize) -> bool {
if let Some(before) = start.checked_sub(1).and_then(|idx| bytes.get(idx))
&& matches!(*before, b'/' | b'\\' | b'.')
{
return true;
}

if is_uri_reference_match(bytes, start, end) {
return true;
}

let Some(after) = bytes.get(end) else {
return false;
};
if matches!(*after, b'/' | b'\\') {
return true;
}

*after == b'.'
&& bytes
.get(end + 1)
.is_some_and(|byte| is_reference_suffix_byte(*byte))
}

fn is_uri_reference_match(bytes: &[u8], start: usize, end: usize) -> bool {
if bytes.get(end) == Some(&b':')
&& bytes
.get(end + 1)
.is_some_and(|byte| !byte.is_ascii_whitespace())
&& is_uri_scheme(&bytes[start..end])
{
return true;
}

let token_start = bytes[..start]
.iter()
.rposition(u8::is_ascii_whitespace)
.map_or(0, |idx| idx + 1);

bytes[token_start..start]
.iter()
.enumerate()
.any(|(offset, byte)| {
if *byte != b':' {
return false;
}

let colon = token_start + offset;
let mut scheme_start = colon;
while scheme_start > token_start && is_uri_scheme_byte(bytes[scheme_start - 1]) {
scheme_start -= 1;
}
is_uri_scheme(&bytes[scheme_start..colon])
})
}

fn is_uri_scheme(bytes: &[u8]) -> bool {
let Some((first, rest)) = bytes.split_first() else {
return false;
};
first.is_ascii_alphabetic() && rest.iter().copied().all(is_uri_scheme_byte)
}

fn is_uri_scheme_byte(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'-' | b'.')
}

fn is_reference_suffix_byte(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')
}

fn is_word_byte(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || byte == b'_'
}
Expand Down
109 changes: 109 additions & 0 deletions codex-rs/external-agent-migration/src/rewrite_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,119 @@ use pretty_assertions::assert_eq;
const PROFILE: RewriteProfile = RewriteProfile::new("SOURCE.md", &["source agent"])
.with_case_sensitive_term_variants(&["Source"]);

const CLAUDE_PROFILE: RewriteProfile = RewriteProfile::new(
"CLAUDE.md",
&[
"claude code",
"claude-code",
"claude_code",
"claudecode",
"claude",
],
);

#[test]
fn rewrites_terms_only_at_word_boundaries() {
assert_eq!(
PROFILE.rewrite("SOURCE.md Source source agent source_agent"),
"AGENTS.md Codex Codex source_agent"
);
}

#[test]
fn preserves_source_specific_filesystem_references() {
assert_eq!(
PROFILE.rewrite(
"Read ./SOURCE.md, C:\\repo\\SOURCE.md, ~/.Source/config, and .Source before using Source."
),
"Read ./SOURCE.md, C:\\repo\\SOURCE.md, ~/.Source/config, and .Source before using Codex."
);
}

#[test]
fn preserves_source_specific_urls_and_dotted_identifiers() {
assert_eq!(
PROFILE.rewrite(
"See https://Source.dev/docs, docs.Source.dev, and plugin.Source.config. Source."
),
"See https://Source.dev/docs, docs.Source.dev, and plugin.Source.config. Codex."
);
}

#[test]
fn rewrites_standalone_doc_name_but_not_path_qualified_doc_name() {
assert_eq!(
PROFILE.rewrite("SOURCE.md lives beside ../SOURCE.md and $HOME/SOURCE.md."),
"AGENTS.md lives beside ../SOURCE.md and $HOME/SOURCE.md."
);
}

#[test]
fn preserves_claude_skill_paths_while_retargeting_prose() {
let input = concat!(
"Session transcripts live in ~/.claude/projects/<slug>/.\n",
"See also .claude/commands/example.md and C:\\Users\\me\\.claude\\plans\\today.md.\n",
"Use Claude Code UI and read CLAUDE.md."
);
let expected = concat!(
"Session transcripts live in ~/.claude/projects/<slug>/.\n",
"See also .claude/commands/example.md and C:\\Users\\me\\.claude\\plans\\today.md.\n",
"Use Codex UI and read AGENTS.md."
);

assert_eq!(CLAUDE_PROFILE.rewrite(input), expected);
}

#[test]
fn preserves_source_specific_terms_anywhere_inside_urls() {
let input = concat!(
"See https://example.test/?agent=claude#claude, ",
"<https://example.test/search?q=Claude>, and claude://session?id=claude. ",
"Use Claude."
);
let expected = concat!(
"See https://example.test/?agent=claude#claude, ",
"<https://example.test/search?q=Claude>, and claude://session?id=claude. ",
"Use Codex."
);

assert_eq!(CLAUDE_PROFILE.rewrite(input), expected);
}

#[test]
fn preserves_query_url_variants() {
assert_eq!(
CLAUDE_PROFILE.rewrite("https://example.test/?agent=claude"),
"https://example.test/?agent=claude"
);
assert_eq!(
CLAUDE_PROFILE.rewrite("https://[::1]/docs(foo)?agent=Claude"),
"https://[::1]/docs(foo)?agent=Claude"
);
}

#[test]
fn preserves_quoted_paths_and_dotted_identifiers() {
let input = r#"Read "/opt/Claude Code/config", 'C:\Program Files\Claude Code\config', \\server\Claude\config, and plugin.Claude.config. Use Claude Code."#;
let expected = r#"Read "/opt/Claude Code/config", 'C:\Program Files\Claude Code\config', \\server\Claude\config, and plugin.Claude.config. Use Codex."#;

assert_eq!(CLAUDE_PROFILE.rewrite(input), expected);
}

#[test]
fn preserves_non_hierarchical_uri_references_without_hiding_prose_labels() {
let input = concat!(
"claude:session, ",
"urn:claude:session, ",
"mailto:claude@example.com. ",
"Claude: use the UI."
);
let expected = concat!(
"claude:session, ",
"urn:claude:session, ",
"mailto:claude@example.com. ",
"Codex: use the UI."
);

assert_eq!(CLAUDE_PROFILE.rewrite(input), expected);
}
Loading