diff --git a/codex-rs/external-agent-migration/src/rewrite.rs b/codex-rs/external-agent-migration/src/rewrite.rs index e1558850ddad..bf995d3bb500 100644 --- a/codex-rs/external-agent-migration/src/rewrite.rs +++ b/codex-rs/external-agent-migration/src/rewrite.rs @@ -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; @@ -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; @@ -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'_' } diff --git a/codex-rs/external-agent-migration/src/rewrite_tests.rs b/codex-rs/external-agent-migration/src/rewrite_tests.rs index 8b103cb3e11c..05d5cdc09a3e 100644 --- a/codex-rs/external-agent-migration/src/rewrite_tests.rs +++ b/codex-rs/external-agent-migration/src/rewrite_tests.rs @@ -4,6 +4,17 @@ 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!( @@ -11,3 +22,101 @@ fn rewrites_terms_only_at_word_boundaries() { "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//.\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//.\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, ", + ", and claude://session?id=claude. ", + "Use Claude." + ); + let expected = concat!( + "See https://example.test/?agent=claude#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); +}