diff --git a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs index 0635c49a38..4cf6a50b99 100644 --- a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs +++ b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs @@ -372,23 +372,13 @@ async fn external_agent_config_secondary_source_imports_session_and_plugin_end_t { let codex_home = TempDir::new()?; let source_home = secondary_external_agent_home(codex_home.path()); - let project_root = codex_home.path().join("workspace with.dots_and-dashes"); + let project_root = codex_home.path().join("my-project"); std::fs::create_dir_all(&project_root)?; let encoded_project = project_root .to_string_lossy() .trim_start_matches(['/', '\\']) - .chars() - .map(|character| { - if character.is_ascii_alphanumeric() { - character - } else { - '-' - } - }) - .collect::(); - #[cfg(windows)] - let encoded_project = encoded_project.replacen("--", "-", /*count*/ 1); + .replace([':', '/', '\\'], "-"); let session_path = source_home .join("projects") .join(encoded_project) diff --git a/codex-rs/external-agent-migration/src/detect/sessions/cur.rs b/codex-rs/external-agent-migration/src/detect/sessions/cur.rs index eb1400beee..a89abe1084 100644 --- a/codex-rs/external-agent-migration/src/detect/sessions/cur.rs +++ b/codex-rs/external-agent-migration/src/detect/sessions/cur.rs @@ -8,6 +8,10 @@ use std::io; use std::path::Path; use std::path::PathBuf; +const MAX_CUR_PROJECT_PATH_PROBES: usize = 128; +const CUR_PROJECT_SEPARATORS: [&str; 11] = + ["-", "_", ".", " ", "--", "..", "__", " ", "+", "@", "&"]; + pub fn detect_recent_cur_sessions( external_agent_home: &Path, codex_home: &Path, @@ -84,84 +88,126 @@ fn cur_project_cwd(project_storage: &Path) -> Option { decode_cur_project_path(encoded) } -#[cfg(not(windows))] fn decode_cur_project_path(encoded: &str) -> Option { - let root = Path::new("/"); - let mut matches = Vec::new(); - collect_cur_project_paths(encoded, root, root, /*depth*/ 0, &mut matches); - if let Some(encoded) = encoded.strip_prefix('-') { - collect_cur_project_paths(encoded, root, root, /*depth*/ 0, &mut matches); + #[cfg(not(windows))] + let mut path = PathBuf::from("/"); + + #[cfg(windows)] + let (encoded, mut path) = { + let (drive, encoded) = decode_cur_windows_project_drive(encoded)?; + (encoded, PathBuf::from(format!("{drive}:\\"))) + }; + + let encoded = encoded.strip_prefix('-').unwrap_or(encoded); + for component in encoded.split('-') { + if component.is_empty() + || matches!(component, "." | "..") + || component.contains(['/', '\\', ':']) + { + return None; + } + path.push(component); } - unique_path(matches) + + let mut matched_path = None; + let mut probes = 0; + let mut inspect = |candidate: PathBuf| { + if probes >= MAX_CUR_PROJECT_PATH_PROBES { + return None; + } + probes += 1; + if candidate.is_dir() { + if matched_path + .as_ref() + .is_some_and(|matched_path| matched_path != &candidate) + { + return None; + } + matched_path = Some(candidate); + } + Some(()) + }; + inspect(path.clone())?; + + for suffix_length in 2..=4 { + let mut parent = path.as_path(); + let mut suffix = Vec::with_capacity(suffix_length); + for _ in 0..suffix_length { + let Some(component) = parent.file_name().and_then(|name| name.to_str()) else { + break; + }; + suffix.push(component); + let Some(ancestor) = parent.parent() else { + break; + }; + parent = ancestor; + } + if suffix.len() != suffix_length { + break; + } + suffix.reverse(); + + for separator in CUR_PROJECT_SEPARATORS { + inspect(parent.join(suffix.join(separator)))?; + } + } + + let mut ancestor = path.parent(); + while let Some(right) = ancestor { + let Some(right_name) = right.file_name().and_then(|name| name.to_str()) else { + break; + }; + let Some(left) = right.parent() else { + break; + }; + let Some(left_name) = left.file_name().and_then(|name| name.to_str()) else { + break; + }; + let Some(prefix) = left.parent() else { + break; + }; + let Ok(trailing) = path.strip_prefix(right) else { + return None; + }; + + for separator in CUR_PROJECT_SEPARATORS { + let merged_prefix = prefix.join(format!("{left_name}{separator}{right_name}")); + if probes >= MAX_CUR_PROJECT_PATH_PROBES { + return None; + } + probes += 1; + if !merged_prefix.is_dir() { + continue; + } + + if probes >= MAX_CUR_PROJECT_PATH_PROBES { + return None; + } + probes += 1; + let candidate = merged_prefix.join(trailing); + if !candidate.is_dir() + || matched_path + .as_ref() + .is_some_and(|matched_path| matched_path != &candidate) + { + return None; + } + matched_path = Some(candidate); + } + ancestor = Some(left); + } + + matched_path } -#[cfg(windows)] -fn decode_cur_project_path(encoded: &str) -> Option { +#[cfg(any(windows, test))] +fn decode_cur_windows_project_drive(encoded: &str) -> Option<(char, &str)> { let drive = encoded.as_bytes().first().copied()?; if !drive.is_ascii_alphabetic() || encoded.as_bytes().get(1) != Some(&b'-') { return None; } - let encoded = encoded.get(2..)?; - let base = PathBuf::from(format!("{}:\\", char::from(drive))); - let mut matches = Vec::new(); - collect_cur_project_paths(encoded, &base, &base, /*depth*/ 0, &mut matches); - unique_path(matches) -} -fn collect_cur_project_paths( - encoded: &str, - base: &Path, - root: &Path, - depth: usize, - matches: &mut Vec, -) { - if encoded.is_empty() || depth > 32 || matches.len() > 1 { - return; - } - let Ok(entries) = fs::read_dir(base) else { - return; - }; - for entry in entries.flatten() { - if matches.len() > 1 { - break; - } - let candidate = entry.path(); - if !candidate.is_dir() { - continue; - } - let Ok(candidate_from_root) = candidate.strip_prefix(root) else { - continue; - }; - let candidate_slug = cur_project_path_slug(candidate_from_root); - if candidate_slug == encoded { - if !matches.contains(&candidate) { - matches.push(candidate); - } - } else if encoded - .strip_prefix(&candidate_slug) - .is_some_and(|remaining| remaining.starts_with('-')) - { - collect_cur_project_paths(encoded, &candidate, root, depth + 1, matches); - } - } -} - -fn cur_project_path_slug(path: &Path) -> String { - path.to_string_lossy() - .trim_start_matches(['/', '\\']) - .chars() - .map(|character| { - if character.is_ascii_alphanumeric() { - character - } else { - '-' - } - }) - .collect() -} - -fn unique_path(mut matches: Vec) -> Option { - (matches.len() == 1).then(|| matches.swap_remove(0)) + Some((char::from(drive), encoded.get(2..)?)) } #[cfg(test)] diff --git a/codex-rs/external-agent-migration/src/detect/sessions/cur_tests.rs b/codex-rs/external-agent-migration/src/detect/sessions/cur_tests.rs index f9f4bb443c..ff44e40541 100644 --- a/codex-rs/external-agent-migration/src/detect/sessions/cur_tests.rs +++ b/codex-rs/external-agent-migration/src/detect/sessions/cur_tests.rs @@ -138,6 +138,94 @@ fn rejects_ambiguous_encoded_project_cwd() { ); } +#[test] +fn resolves_cur_project_names_with_common_separators() { + for (project_name, encoded_name) in [ + ("project", "project"), + ("my-project", "my-project"), + ("my--project", "my-project"), + ("my project", "my-project"), + ("my.project", "my-project"), + ("my..project", "my-project"), + ("my_project", "my-project"), + ("my+project", "my-project"), + ("my@project", "my-project"), + ("my&project", "my-project"), + ("my-awesome-project", "my-awesome-project"), + ("my-awesome-cool-project", "my-awesome-cool-project"), + ] { + let root = TempDir::new().expect("tempdir"); + let project = root.path().join(project_name); + fs::create_dir_all(&project).expect("project root"); + let encoded = format!("{}-{encoded_name}", encode_project_path(root.path())); + + assert_eq!(decode_cur_project_path(&encoded), Some(project)); + } +} + +#[test] +fn rejects_ambiguous_cur_project_without_a_direct_match() { + let root = TempDir::new().expect("tempdir"); + for project_name in ["my-project", "my project", "my+project"] { + fs::create_dir_all(root.path().join(project_name)).expect("project root"); + } + let encoded = format!("{}-my-project", encode_project_path(root.path())); + + assert_eq!(decode_cur_project_path(&encoded), None); +} + +#[test] +fn rejects_ambiguous_cur_project_with_punctuated_ancestor() { + let root = TempDir::new().expect("tempdir"); + let punctuated_ancestor = root.path().join("a-b").join("c"); + let punctuated_leaf = root.path().join("a").join("b-c"); + fs::create_dir_all(&punctuated_ancestor).expect("punctuated ancestor project"); + fs::create_dir_all(&punctuated_leaf).expect("punctuated leaf project"); + let encoded = encode_project_path(&punctuated_ancestor); + + assert_eq!(encoded, encode_project_path(&punctuated_leaf)); + assert_eq!(decode_cur_project_path(&encoded), None); +} + +#[test] +fn rejects_ambiguous_cur_project_with_multiple_punctuated_ancestors() { + for (first, second) in [ + (&["a-b", "c-d", "e"][..], &["a", "b", "c", "d-e"][..]), + (&["a-b", "c-d"][..], &["a", "b", "c", "d"][..]), + ] { + let root = TempDir::new().expect("tempdir"); + let first = first + .iter() + .fold(root.path().to_path_buf(), |path, component| { + path.join(component) + }); + let second = second + .iter() + .fold(root.path().to_path_buf(), |path, component| { + path.join(component) + }); + fs::create_dir_all(&first).expect("first project"); + fs::create_dir_all(&second).expect("second project"); + let encoded = encode_project_path(&first); + + assert_eq!(encoded, encode_project_path(&second)); + assert_eq!(decode_cur_project_path(&encoded), None); + } +} + +#[test] +fn parses_windows_cursor_fixture_project_directory() { + assert_eq!( + decode_cur_windows_project_drive("C--Users-fixture-Cursor"), + Some(('C', "-Users-fixture-Cursor")) + ); + assert_eq!( + decode_cur_windows_project_drive("C-Users-fixture-Cursor"), + Some(('C', "Users-fixture-Cursor")) + ); + assert_eq!(decode_cur_windows_project_drive("1-Users-fixture"), None); +} + #[test] fn ignores_cur_sessions_older_than_import_window() { let root = TempDir::new().expect("tempdir"); @@ -296,10 +384,12 @@ fn set_modified_at(path: &Path, modified_at: SystemTime) { #[cfg(windows)] fn encode_project_path(path: &Path) -> String { - cur_project_path_slug(path).replacen("--", "-", 1) + path.to_string_lossy().replace([':', '\\', '/'], "-") } #[cfg(not(windows))] fn encode_project_path(path: &Path) -> String { - cur_project_path_slug(path) + path.to_string_lossy() + .trim_start_matches('/') + .replace('/', "-") }