From 3899ca3f589bec6c22eb5ebf3c4bd7b65a69efd8 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Sun, 31 May 2026 02:53:08 -0700 Subject: [PATCH] feat(project-context): stabilize pack ordering (#2418) Harvested from #2392 with thanks to @wplll. Makes project context pack path ordering deterministic across Unix and Windows-style separators while keeping README/config/source entries prioritized before general directory noise. --- crates/tui/src/project_context.rs | 101 ++++++++++++++++++++++++++++-- 1 file changed, 95 insertions(+), 6 deletions(-) diff --git a/crates/tui/src/project_context.rs b/crates/tui/src/project_context.rs index d6c3a4c5..70af398c 100644 --- a/crates/tui/src/project_context.rs +++ b/crates/tui/src/project_context.rs @@ -170,7 +170,7 @@ struct ReadmePack { pub fn generate_project_context_pack(workspace: &Path) -> Option { let mut entries = Vec::new(); collect_pack_entries(workspace, workspace, 0, &mut entries); - entries.sort(); + sort_pack_paths(&mut entries); entries.truncate(PACK_MAX_ENTRIES); let mut config_files = entries @@ -179,7 +179,7 @@ pub fn generate_project_context_pack(workspace: &Path) -> Option { .take(PACK_MAX_CONFIG_FILES) .cloned() .collect::>(); - config_files.sort(); + sort_pack_paths(&mut config_files); let mut key_source_files = entries .iter() @@ -187,7 +187,7 @@ pub fn generate_project_context_pack(workspace: &Path) -> Option { .take(PACK_MAX_SOURCE_FILES) .cloned() .collect::>(); - key_source_files.sort(); + sort_pack_paths(&mut key_source_files); let readme = read_readme_excerpt(workspace, &entries); let mut counts = BTreeMap::new(); @@ -289,10 +289,50 @@ fn relative_slash_path(root: &Path, path: &Path) -> Option { for component in relative.components() { parts.push(component.as_os_str().to_string_lossy().to_string()); } - if parts.is_empty() { - None + normalize_pack_relative_path(&parts.join("/")) +} + +fn normalize_pack_relative_path(path: &str) -> Option { + let normalized = path.replace('\\', "/"); + let mut parts = Vec::new(); + for part in normalized.split('/') { + if part.is_empty() || part == "." { + continue; + } + if part == ".." { + return None; + } + parts.push(part); + } + (!parts.is_empty()).then(|| parts.join("/")) +} + +fn sort_pack_paths(paths: &mut [String]) { + paths.sort_by(|a, b| { + pack_path_priority(a) + .cmp(&pack_path_priority(b)) + .then_with(|| pack_path_sort_key(a).cmp(&pack_path_sort_key(b))) + .then_with(|| a.cmp(b)) + }); +} + +fn pack_path_sort_key(path: &str) -> String { + path.replace('\\', "/").to_ascii_lowercase() +} + +fn pack_path_priority(path: &str) -> u8 { + let lower = pack_path_sort_key(path); + let name = lower.trim_end_matches('/').rsplit('/').next().unwrap_or(""); + if matches!(name, "readme.md" | "readme.txt" | "readme") { + 0 + } else if is_config_file(&lower) { + 1 + } else if is_source_file(&lower) { + 2 + } else if lower.ends_with('/') { + 3 } else { - Some(parts.join("/")) + 4 } } @@ -1029,6 +1069,55 @@ mod tests { ); } + #[test] + fn project_context_pack_sort_is_cross_platform_and_priority_aware() { + let mut unix_paths = vec![ + "src/z.rs".to_string(), + "docs/".to_string(), + "README.md".to_string(), + "Cargo.toml".to_string(), + "src/a.rs".to_string(), + "notes.txt".to_string(), + ]; + let mut windows_paths = vec![ + "src\\z.rs".to_string(), + "docs\\".to_string(), + "README.md".to_string(), + "Cargo.toml".to_string(), + "src\\a.rs".to_string(), + "notes.txt".to_string(), + ]; + + sort_pack_paths(&mut unix_paths); + sort_pack_paths(&mut windows_paths); + + let normalized_windows = windows_paths + .iter() + .map(|path| path.replace('\\', "/")) + .collect::>(); + assert_eq!(unix_paths, normalized_windows); + assert_eq!( + unix_paths, + vec![ + "README.md", + "Cargo.toml", + "src/a.rs", + "src/z.rs", + "docs/", + "notes.txt", + ] + ); + } + + #[test] + fn normalize_pack_relative_path_rejects_parent_segments() { + assert_eq!( + normalize_pack_relative_path(".\\src\\main.rs"), + Some("src/main.rs".to_string()) + ); + assert_eq!(normalize_pack_relative_path("../secret.txt"), None); + } + #[test] fn test_load_global_agents_when_project_has_no_context() { let workspace = tempdir().expect("workspace tempdir");