//! Data-driven tests for path-not-found hint logic using synthetic filesystem //! layouts that exercise common model path-guess failure modes. //! //! Each test sets up a temporary filesystem layout, calls //! [`path_not_found_hint`] or [`format_not_found_error`], and asserts the //! output matches what we want the model to see. //! //! Run: //! ```bash //! cargo test -p kigi-tools --test path_suggestions_production //! ``` use kigi_tools::util::path_suggestions::{format_not_found_error, path_not_found_hint}; use std::path::PathBuf; use tempfile::TempDir; /// Set up a temp dir with the given dirs and files, then return (tmpdir, root). fn setup_fs(dirs: &[&str], files: &[&str]) -> (TempDir, PathBuf) { let tmp = TempDir::new().unwrap(); let root = tmp.path().to_path_buf(); for d in dirs { std::fs::create_dir_all(root.join(d)).unwrap(); } for f in files { let p = root.join(f); if let Some(parent) = p.parent() { std::fs::create_dir_all(parent).unwrap(); } std::fs::write(&p, b"").unwrap(); } (tmp, root) } /// Extract leaf file names from the `similar` vec for assertion. fn similar_names(hint: &kigi_tools::util::path_suggestions::PathNotFoundHint) -> Vec { hint.similar .iter() .filter_map(|p| p.file_name().map(|n| n.to_string_lossy().to_string())) .collect() } // Pattern 1: Hallucinated deep paths — model guesses plausible paths where // the parent directory exists but the leaf file doesn't. // // Examples: // path: features/billing/impl/src/.../BillingFeaturesImpl.kt // path: subsystem/core/components/impl/src/test // path: .github/PULL_REQUEST_TEMPLATE.md #[tokio::test] async fn pattern1_parent_exists_wrong_leaf_suggests_similar() { let (_tmp, root) = setup_fs( &["features/billing/impl/src/main/kotlin/com/example/billing"], &["features/billing/impl/src/main/kotlin/com/example/billing/BillingFeatures.kt"], ); let cwd = root.clone(); let missing = root .join("features/billing/impl/src/main/kotlin/com/example/billing/BillingFeaturesImpl.kt"); let hint = path_not_found_hint(&missing, &cwd, &cwd).await; assert!(hint.suggestion.is_none(), "dropped-folder should not fire"); let names = similar_names(&hint); assert!( names.iter().any(|n| n == "BillingFeatures.kt"), "should suggest BillingFeatures.kt, got: {names:?}" ); } #[tokio::test] async fn pattern1_pluralization_typo() { let (_tmp, root) = setup_fs(&["libs"], &[]); let cwd = root.clone(); let missing = root.join("lib"); let hint = path_not_found_hint(&missing, &cwd, &cwd).await; let names = similar_names(&hint); assert!( names.iter().any(|n| n == "libs"), "should suggest 'libs' for 'lib', got: {names:?}" ); } #[tokio::test] async fn pattern1_missing_extension() { let (_tmp, root) = setup_fs(&[], &["README.md"]); let cwd = root.clone(); let missing = root.join("README"); let hint = path_not_found_hint(&missing, &cwd, &cwd).await; let names = similar_names(&hint); assert!( names.iter().any(|n| n == "README.md"), "should suggest README.md for README, got: {names:?}" ); } #[tokio::test] async fn pattern1_wrong_suffix() { let (_tmp, root) = setup_fs(&["src/util"], &["src/util/helper.rs"]); let cwd = root.clone(); let missing = root.join("src/util/helpers.rs"); let hint = path_not_found_hint(&missing, &cwd, &cwd).await; let names = similar_names(&hint); assert!( names.iter().any(|n| n == "helper.rs"), "should suggest helper.rs for helpers.rs, got: {names:?}" ); } #[tokio::test] async fn pattern1_parent_dir_itself_missing() { // Parent dir is missing too — should degrade gracefully, not crash. let (_tmp, root) = setup_fs(&[], &[]); let cwd = root.clone(); let missing = root.join("nonexistent_dir/foo.rs"); let hint = path_not_found_hint(&missing, &cwd, &cwd).await; assert!(hint.suggestion.is_none()); assert!(hint.similar.is_empty(), "no parent dir to scan"); assert!(!hint.cwd_note.is_empty(), "CWD note always present"); } #[tokio::test] async fn pattern1_contributing_md_guess() { // A hallucinated common file with nothing similar in the tree → no suggestion. let (_tmp, root) = setup_fs(&[".github"], &["LICENSE", "Cargo.toml"]); let cwd = root.clone(); let missing = root.join("CONTRIBUTING.md"); let hint = path_not_found_hint(&missing, &cwd, &cwd).await; assert!(hint.suggestion.is_none()); assert!(!hint.cwd_note.is_empty()); } // Pattern 2: Absolute paths to wrong locations — model uses absolute paths // pointing outside CWD (other user homes, worktree internals, cargo registry). // // Examples: // path: /Users/alice/.cargo/registry/... (cwd: /Users/alice/project) // path: /tmp/.tool/sessions/%2F.../terminal/.. (cwd: /workspace/repo) // path: /Users/bob/workspace/worktrees/app/.. (cwd: /Users/bob/workspace/app/...) #[tokio::test] async fn pattern2_absolute_path_completely_different_tree() { let (_tmp, root) = setup_fs(&["src"], &["src/foo.rs"]); let cwd = root.clone(); let unrelated = PathBuf::from("/Users/other/project/src/foo.rs"); let hint = path_not_found_hint(&unrelated, &cwd, &cwd).await; assert!(hint.suggestion.is_none()); assert!(hint.similar.is_empty()); assert!(hint.cwd_note.contains(&cwd.display().to_string())); } #[tokio::test] async fn pattern2_kigi_sessions_internal_path() { let (_tmp, root) = setup_fs(&["src"], &[]); let cwd = root.clone(); let internal = PathBuf::from("/tmp/.tool/sessions/%2Fworkspace%2Frepo/abc-123/terminal/log.txt"); let hint = path_not_found_hint(&internal, &cwd, &cwd).await; assert!(hint.suggestion.is_none()); assert!(hint.similar.is_empty()); } // Pattern 3: Dropped repo folder — model omits the repo directory name from // the path. E.g. asks for /parent/src when CWD is /parent/repo and // /parent/repo/src exists. // // This is the primary target of try_suggest_under_cwd(). #[tokio::test] async fn pattern3_dropped_folder_with_display_remap() { // Worktree scenario: resolved cwd differs from display cwd. // Suggestion must show the display path, not the resolved path. let (_tmp, root) = setup_fs(&["worktree/project/src"], &[]); let resolved_cwd = root.join("worktree/project"); let display_cwd = PathBuf::from("/home/user/project"); // Model asks for /home/user/src (dropped "project" folder from display path). // But try_suggest_under_cwd works on resolved paths, so we need the resolved // equivalent: root/worktree/src. let resolved_missing = root.join("worktree/src"); let hint = path_not_found_hint(&resolved_missing, &resolved_cwd, &display_cwd).await; if let Some(ref suggestion) = hint.suggestion { // Suggestion must be in display space, not resolved space. let s = suggestion.display().to_string(); assert!( s.contains("/home/user/project/"), "suggestion should use display path, got: {s}" ); assert!( !s.contains("worktree"), "suggestion must NOT leak resolved worktree path, got: {s}" ); } else { panic!("expected a dropped-folder suggestion"); } } #[tokio::test] async fn pattern3_dropped_folder_relative_path_skipped() { let (_tmp, root) = setup_fs(&["repo/src"], &[]); let cwd = root.join("repo"); let relative_missing = PathBuf::from("src/nonexistent.rs"); let hint = path_not_found_hint(&relative_missing, &cwd, &cwd).await; assert!( hint.suggestion.is_none(), "relative paths must not trigger dropped-folder detection" ); } // Pattern 4: Common prefix guesses — model uses src/, app/, lib/ as first // component but the repo doesn't have that top-level dir, or uses a variant. // // Examples: // path: src/search_engine/index.py (repo has no top-level src/) // path: lib/utils.rs (repo uses libs/ not lib/) // path: app/_components/galaxy (wrong component dir name) #[tokio::test] async fn pattern4_lib_vs_libs() { let (_tmp, root) = setup_fs(&["libs"], &["libs/utils.rs"]); let cwd = root.clone(); let missing = root.join("lib"); let hint = path_not_found_hint(&missing, &cwd, &cwd).await; let names = similar_names(&hint); assert!( names.iter().any(|n| n == "libs"), "should suggest 'libs' when model asks for 'lib', got: {names:?}" ); } #[tokio::test] async fn pattern4_src_does_not_exist_no_misleading_suggestion() { let (_tmp, root) = setup_fs(&["python", "scripts"], &["setup.py"]); let cwd = root.clone(); let missing = root.join("src"); let hint = path_not_found_hint(&missing, &cwd, &cwd).await; assert!(hint.suggestion.is_none()); let names = similar_names(&hint); // "src" is 3 chars; should not match "python", "scripts", or "setup.py" assert!( !names.iter().any(|n| n == "setup.py"), "should not suggest unrelated files, got: {names:?}" ); } // Pattern 5: Root-level file guesses — model guesses a file exists at the // repo root when it doesn't (CONTRIBUTING.md, .github, etc). #[tokio::test] async fn pattern5_root_file_with_close_match() { let (_tmp, root) = setup_fs(&[], &["CHANGELOG.md"]); let cwd = root.clone(); let missing = root.join("CHANGELOG"); let hint = path_not_found_hint(&missing, &cwd, &cwd).await; let names = similar_names(&hint); assert!( names.iter().any(|n| n == "CHANGELOG.md"), "should suggest CHANGELOG.md, got: {names:?}" ); } #[tokio::test] async fn pattern5_root_file_no_match() { let (_tmp, root) = setup_fs(&["crates", "scripts"], &["Cargo.toml", "Cargo.lock"]); let cwd = root.clone(); let missing = root.join("example-cli-tool"); let hint = path_not_found_hint(&missing, &cwd, &cwd).await; assert!(hint.suggestion.is_none()); // Unrelated root query should not substring-match "crates", "scripts", etc. let names = similar_names(&hint); assert!( names.is_empty(), "should have no similar names for unrelated query, got: {names:?}" ); } // format_not_found_error — integration tests verifying the full formatted // output string for each major pattern. #[tokio::test] async fn format_hallucinated_deep_path_with_similar() { let (_tmp, root) = setup_fs( &["src/components"], &[ "src/components/Button.tsx", "src/components/ButtonGroup.tsx", ], ); let cwd = root.clone(); let missing = root.join("src/components/Buttons.tsx"); let msg = format_not_found_error(&missing, &missing, &cwd, &cwd, true).await; assert!(msg.contains("does not exist"), "msg: {msg}"); assert!( msg.contains("Similar entries in parent directory"), "should show similar names, msg: {msg}" ); assert!( msg.contains("Button.tsx") || msg.contains("ButtonGroup.tsx"), "should suggest a Button variant, msg: {msg}" ); assert!( msg.contains("Note: your current working directory"), "msg: {msg}" ); } #[tokio::test] async fn format_hints_disabled_bare_error() { let (_tmp, root) = setup_fs(&["src"], &["src/real.rs"]); let cwd = root.clone(); let missing = root.join("src/fake.rs"); let msg = format_not_found_error(&missing, &missing, &cwd, &cwd, false).await; assert!(msg.contains("does not exist"), "msg: {msg}"); assert!(!msg.contains("Note:"), "no hints when disabled, msg: {msg}"); assert!( !msg.contains("Similar"), "no suggestions when disabled, msg: {msg}" ); assert!( !msg.contains("Did you mean"), "no suggestions when disabled, msg: {msg}" ); } #[tokio::test] async fn format_no_match_just_cwd_note() { let (_tmp, root) = setup_fs(&[], &["totally_unrelated.py"]); let cwd = root.clone(); let missing = root.join("xyz_nonexistent"); let msg = format_not_found_error(&missing, &missing, &cwd, &cwd, true).await; assert!(msg.contains("does not exist"), "msg: {msg}"); assert!( msg.contains("Note: your current working directory"), "msg: {msg}" ); assert!(!msg.contains("Did you mean"), "msg: {msg}"); assert!(!msg.contains("Similar"), "msg: {msg}"); } #[tokio::test] async fn format_dropped_folder_shows_display_path() { let (_tmp, root) = setup_fs(&["repo/src"], &[]); let cwd = root.join("repo"); // display and resolved cwd are identical here let display_cwd = &cwd; // dropped "repo" let bad_path = root.join("src"); let msg = format_not_found_error(&bad_path, &bad_path, &cwd, display_cwd, true).await; assert!(msg.contains("does not exist"), "msg: {msg}"); assert!(msg.contains("Did you mean"), "msg: {msg}"); assert!( msg.contains("Note: your current working directory"), "msg: {msg}" ); }