diff --git a/.gitignore b/.gitignore index 168879e..82190ce 100644 --- a/.gitignore +++ b/.gitignore @@ -34,5 +34,6 @@ reflex *.jpeg *.gif - -*.snap \ No newline at end of file +# Insta pending-snapshot files — committed `.snap` files are the baseline +*.snap.new +*.pending-snap \ No newline at end of file diff --git a/src/cli/index.rs b/src/cli/index.rs index cc45510..7ea66e6 100644 --- a/src/cli/index.rs +++ b/src/cli/index.rs @@ -228,7 +228,7 @@ pub(super) fn handle_index_build( std::process::Command::new(¤t_exe) .arg("index-symbols-internal") - .arg(&path) + .arg(path) .creation_flags(CREATE_NO_WINDOW) .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) diff --git a/src/dependency.rs b/src/dependency.rs index 9a87a8e..f14e795 100644 --- a/src/dependency.rs +++ b/src/dependency.rs @@ -1216,11 +1216,12 @@ pub fn resolve_rust_import( } } - // Convert to string and make relative to project root + // Convert to string and make relative to project root. + // Normalize to forward slashes so paths are deterministic across platforms. resolved_path.and_then(|p| { p.strip_prefix(project_root) .ok() - .map(|rel| rel.to_string_lossy().to_string()) + .map(|rel| rel.to_string_lossy().replace('\\', "/")) }) } @@ -1290,13 +1291,13 @@ pub fn resolve_rust_mod_declaration( // Try sibling file let sibling = current_dir.join(format!("{}.rs", mod_name)); if sibling.exists() { - return Some(sibling.to_string_lossy().to_string()); + return Some(sibling.to_string_lossy().replace('\\', "/")); } // Try directory module let dir_mod = current_dir.join(mod_name).join("mod.rs"); if dir_mod.exists() { - return Some(dir_mod.to_string_lossy().to_string()); + return Some(dir_mod.to_string_lossy().replace('\\', "/")); } None diff --git a/src/indexer.rs b/src/indexer.rs index 288c599..4ec1b9d 100644 --- a/src/indexer.rs +++ b/src/indexer.rs @@ -17,6 +17,7 @@ use crate::cache::CacheManager; use crate::content_store::{ContentReader, ContentWriter}; use crate::dependency::DependencyIndex; use crate::models::{Dependency, ImportType, IndexConfig, IndexStats, Language}; +#[cfg(unix)] use crate::output; use crate::parsers::c::CDependencyExtractor; use crate::parsers::cpp::CppDependencyExtractor; @@ -41,7 +42,6 @@ pub type ProgressCallback = Arc; /// Result of processing a single file (used for parallel processing) struct FileProcessingResult { - path: PathBuf, path_str: String, hash: String, content: String, @@ -202,14 +202,17 @@ impl Indexer { let mut any_changed = false; for file_path in &files { - // Normalize path to be relative to root (handles both ./ prefix and absolute paths) + // Normalize path to be relative to root (handles both ./ prefix and absolute paths). + // Always use forward slashes so the on-disk index is deterministic across OSes + // and downstream string lookups (file_pattern filters, dependency resolvers) + // work regardless of the host separator. let path_str = file_path.to_string_lossy().to_string(); let normalized_path = if let Ok(rel_path) = file_path.strip_prefix(root) { // Convert absolute path to relative - rel_path.to_string_lossy().to_string() + rel_path.to_string_lossy().replace('\\', "/") } else { // Already relative, just strip ./ prefix - path_str.trim_start_matches("./").to_string() + path_str.trim_start_matches("./").replace('\\', "/") }; // Check if file exists in cache @@ -404,14 +407,15 @@ impl Indexer { batch_files .par_iter() .map(|file_path| { - // Normalize path to be relative to root (handles both ./ prefix and absolute paths) + // Normalize path to be relative to root (handles both ./ prefix and absolute paths). + // Always emit forward slashes so the persisted path is deterministic across OSes. let path_str = file_path.to_string_lossy().to_string(); let normalized_path = if let Ok(rel_path) = file_path.strip_prefix(root) { // Convert absolute path to relative - rel_path.to_string_lossy().to_string() + rel_path.to_string_lossy().replace('\\', "/") } else { // Already relative, just strip ./ prefix - path_str.trim_start_matches("./").to_string() + path_str.trim_start_matches("./").replace('\\', "/") }; // Read file content once (used for hashing, trigrams, and parsing) @@ -605,7 +609,6 @@ impl Indexer { counter_clone.fetch_add(1, Ordering::Relaxed); Some(FileProcessingResult { - path: file_path.clone(), path_str: normalized_path.to_string(), hash, content, @@ -620,14 +623,19 @@ impl Indexer { // Process batch results immediately (streaming approach to minimize memory) for result in results.into_iter().flatten() { + // Use the normalized (forward-slash, relative) path everywhere so + // the trigram index and content store agree with what the database + // and downstream filters expect, regardless of host separator. + let normalized_pathbuf = PathBuf::from(&result.path_str); + // Add file to trigram index (get file_id) - let file_id = trigram_index.add_file(result.path.clone()); + let file_id = trigram_index.add_file(normalized_pathbuf.clone()); // Index file content directly (avoid accumulating all trigrams) trigram_index.index_file(file_id, &result.content); // Add to content store - content_writer.add_file(result.path.clone(), &result.content); + content_writer.add_file(normalized_pathbuf, &result.content); files_indexed += 1; @@ -1206,10 +1214,11 @@ impl Indexer { let normalized_candidate = if let Ok(rel_path) = std::path::Path::new(candidate_path).strip_prefix(root) { - rel_path.to_string_lossy().to_string() + rel_path.to_string_lossy().replace('\\', "/") } else { // Not an absolute path or not under root - use as-is - candidate_path.to_string() + // (still normalize separators so DB lookups match). + candidate_path.replace('\\', "/") }; log::debug!( @@ -1645,10 +1654,11 @@ impl Indexer { let normalized_candidate = if let Ok(rel_path) = std::path::Path::new(candidate_path).strip_prefix(root) { - rel_path.to_string_lossy().to_string() + rel_path.to_string_lossy().replace('\\', "/") } else { // Not an absolute path or not under root - use as-is - candidate_path.to_string() + // (still normalize separators so DB lookups match). + candidate_path.replace('\\', "/") }; match dep_index.get_file_id_by_path(&normalized_candidate) { @@ -2039,6 +2049,7 @@ impl Indexer { /// /// Ensures there's enough free space to create the index. Warns if disk space is low. /// This prevents partial index writes and confusing error messages. + #[cfg_attr(not(unix), allow(unused_variables))] fn check_disk_space(&self, root: &Path) -> Result<()> { // Get available space on the filesystem containing the cache directory let cache_path = self.cache.path(); diff --git a/src/parsers/c.rs b/src/parsers/c.rs index b9b7735..0c39fa0 100644 --- a/src/parsers/c.rs +++ b/src/parsers/c.rs @@ -747,12 +747,13 @@ pub fn resolve_c_include_to_path( // Resolve the include path relative to current file let resolved = current_dir.join(include_path); - // Normalize the path + // Normalize the path. Always emit forward slashes so resolved paths are + // deterministic across platforms. match resolved.canonicalize() { - Ok(normalized) => Some(normalized.display().to_string()), + Ok(normalized) => Some(normalized.to_string_lossy().replace('\\', "/")), Err(_) => { // If canonicalize fails (file doesn't exist yet), return the joined path - Some(resolved.display().to_string()) + Some(resolved.to_string_lossy().replace('\\', "/")) } } } @@ -771,7 +772,7 @@ mod resolution_tests { assert!(result.is_some()); let path = result.unwrap(); - assert!(path.ends_with("src/helper.h") || path.ends_with("src\\helper.h")); + assert!(path.ends_with("src/helper.h")); } #[test] @@ -780,7 +781,7 @@ mod resolution_tests { assert!(result.is_some()); let path = result.unwrap(); - assert!(path.ends_with("src/utils/helper.h") || path.ends_with("src\\utils\\helper.h")); + assert!(path.ends_with("src/utils/helper.h")); } #[test] diff --git a/src/parsers/cpp.rs b/src/parsers/cpp.rs index 9f50fcd..abe9ec0 100644 --- a/src/parsers/cpp.rs +++ b/src/parsers/cpp.rs @@ -1228,12 +1228,13 @@ pub fn resolve_cpp_include_to_path( // Resolve the include path relative to current file let resolved = current_dir.join(include_path); - // Normalize the path + // Normalize the path. Always emit forward slashes so resolved paths are + // deterministic across platforms. match resolved.canonicalize() { - Ok(normalized) => Some(normalized.display().to_string()), + Ok(normalized) => Some(normalized.to_string_lossy().replace('\\', "/")), Err(_) => { // If canonicalize fails (file doesn't exist yet), return the joined path - Some(resolved.display().to_string()) + Some(resolved.to_string_lossy().replace('\\', "/")) } } } @@ -1252,7 +1253,7 @@ mod resolution_tests { assert!(result.is_some()); let path = result.unwrap(); - assert!(path.ends_with("src/helper.hpp") || path.ends_with("src\\helper.hpp")); + assert!(path.ends_with("src/helper.hpp")); } #[test] @@ -1261,7 +1262,7 @@ mod resolution_tests { assert!(result.is_some()); let path = result.unwrap(); - assert!(path.ends_with("src/utils/helper.hpp") || path.ends_with("src\\utils\\helper.hpp")); + assert!(path.ends_with("src/utils/helper.hpp")); } #[test] @@ -1280,7 +1281,7 @@ mod resolution_tests { assert!(result.is_some()); let path = result.unwrap(); - assert!(path.ends_with("src/legacy.h") || path.ends_with("src\\legacy.h")); + assert!(path.ends_with("src/legacy.h")); } #[test] diff --git a/src/parsers/go.rs b/src/parsers/go.rs index 02204ec..b6b5976 100644 --- a/src/parsers/go.rs +++ b/src/parsers/go.rs @@ -1285,8 +1285,9 @@ pub fn find_all_go_mods(index_root: &std::path::Path) -> Result Result Result Result {}", import_path, resolved_alias); // Alias matched! Now resolve relative to the tsconfig directory let resolved_path = map.resolve_relative_to_config(&resolved_alias); - let path_str = resolved_path.to_string_lossy().to_string(); + // Normalize to forward slashes for deterministic, cross-platform output. + let path_str = resolved_path.to_string_lossy().replace('\\', "/"); log::debug!(" After resolve_relative_to_config: {}", path_str); // Check if resolved path has an extension @@ -1576,7 +1577,10 @@ pub fn resolve_ts_import_to_path( }, ); - let normalized = normalized_path.to_string_lossy().to_string(); + // Normalize to forward slashes so the resolved path is deterministic + // across platforms. `join`/`components` on Windows produces backslashes + // which would otherwise break downstream string lookups. + let normalized = normalized_path.to_string_lossy().replace('\\', "/"); // Check if the import already has a known extension // Vue/Svelte files are imported with their extension: import Foo from './Foo.vue' diff --git a/src/pulse/pagefind.rs b/src/pulse/pagefind.rs index ac4cb63..84cb3b4 100644 --- a/src/pulse/pagefind.rs +++ b/src/pulse/pagefind.rs @@ -225,10 +225,17 @@ mod tests { #[test] fn test_get_asset_name() { let result = get_asset_name(); - assert!(result.is_ok(), "Should detect platform: {:?}", result.err()); - let name = result.unwrap(); - assert!(name.contains(PAGEFIND_VERSION)); - assert!(name.ends_with(".tar.gz")); + if cfg!(any(target_os = "linux", target_os = "macos")) { + assert!(result.is_ok(), "Should detect platform: {:?}", result.err()); + let name = result.unwrap(); + assert!(name.contains(PAGEFIND_VERSION)); + assert!(name.ends_with(".tar.gz")); + } else { + // Pagefind has no Windows release asset; the helper should + // explicitly report the platform as unsupported. + let err = result.expect_err("expected unsupported platform error"); + assert!(err.to_string().contains("Unsupported platform")); + } } #[test] diff --git a/src/pulse/zola.rs b/src/pulse/zola.rs index 98947c0..7d192ea 100644 --- a/src/pulse/zola.rs +++ b/src/pulse/zola.rs @@ -231,10 +231,25 @@ mod tests { #[test] fn test_get_asset_name() { let result = get_asset_name(); - assert!(result.is_ok(), "Should detect platform: {:?}", result.err()); - let name = result.unwrap(); - assert!(name.contains(ZOLA_VERSION)); - assert!(name.ends_with(".tar.gz")); + // Zola only ships binaries for linux-x86_64 and both macOS arches. + // On other platforms the helper should explicitly bail. + let supported = matches!( + (std::env::consts::OS, std::env::consts::ARCH), + ("linux", "x86_64") | ("macos", "x86_64") | ("macos", "aarch64") + ); + if supported { + assert!(result.is_ok(), "Should detect platform: {:?}", result.err()); + let name = result.unwrap(); + assert!(name.contains(ZOLA_VERSION)); + assert!(name.ends_with(".tar.gz")); + } else { + let err = result.expect_err("expected unsupported-platform error"); + let msg = err.to_string(); + assert!( + msg.contains("Unsupported platform") || msg.contains("does not have"), + "unexpected error: {msg}" + ); + } } #[test] diff --git a/src/semantic/config.rs b/src/semantic/config.rs index 4076943..69d8536 100644 --- a/src/semantic/config.rs +++ b/src/semantic/config.rs @@ -4,7 +4,26 @@ use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::env; -use std::path::Path; +use std::path::{Path, PathBuf}; + +/// Locate the user's home directory. +/// +/// `dirs::home_dir()` queries `SHGetKnownFolderPath(FOLDERID_Profile)` on +/// Windows and therefore ignores `HOME` / `USERPROFILE` env vars. That makes +/// it impossible to redirect to a temp directory in tests. Honour those env +/// vars (and `REFLEX_HOME` for an explicit override) before falling back to +/// the OS-native lookup so test code can point us at a temp directory on +/// every platform. +fn user_home_dir() -> Option { + for var in ["REFLEX_HOME", "HOME", "USERPROFILE"] { + if let Some(val) = env::var_os(var) + && !val.is_empty() + { + return Some(PathBuf::from(val)); + } + } + dirs::home_dir() +} /// Semantic query configuration #[derive(Debug, Clone, Serialize, Deserialize)] @@ -152,7 +171,7 @@ fn apply_env_overrides(mut config: SemanticConfig) -> SemanticConfig { /// Note: The cache_dir parameter is ignored - kept for API compatibility but will be removed in future. pub fn load_config(_cache_dir: &Path) -> Result { // Semantic config is always in user home directory, not project directory - let home = match dirs::home_dir() { + let home = match user_home_dir() { Some(h) => h, None => { log::debug!("Could not determine home directory, using defaults"); @@ -249,7 +268,7 @@ struct Credentials { /// Load user configuration from ~/.reflex/config.toml fn load_user_config() -> Result> { - let home = match dirs::home_dir() { + let home = match user_home_dir() { Some(h) => h, None => { log::debug!("Could not determine home directory"); @@ -491,7 +510,7 @@ pub fn resolve_model_for( /// Updates the [credentials] section with the new model for the specified provider. /// Creates the config file and directory if they don't exist. pub fn save_user_provider(provider: &str, model: Option<&str>) -> Result<()> { - let home = dirs::home_dir().context("Cannot find home directory")?; + let home = user_home_dir().context("Cannot find home directory")?; let config_dir = home.join(".reflex"); let config_path = config_dir.join("config.toml"); @@ -591,6 +610,29 @@ mod tests { ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()) } + /// Point `dirs::home_dir()` at the given path. On Unix that means + /// `HOME`; on Windows the resolver reads `USERPROFILE` instead, so we + /// must set the platform-appropriate variable for the override to take + /// effect. + fn set_home(path: &std::path::Path) { + unsafe { + env::set_var("HOME", path); + if cfg!(windows) { + env::set_var("USERPROFILE", path); + } + } + } + + /// Reset the home override applied by [`set_home`]. + fn unset_home() { + unsafe { + env::remove_var("HOME"); + if cfg!(windows) { + env::remove_var("USERPROFILE"); + } + } + } + #[test] fn test_default_config() { let config = SemanticConfig::default(); @@ -640,13 +682,9 @@ auto_execute = true .unwrap(); // Set HOME to temp directory to load test config - unsafe { - env::set_var("HOME", temp.path()); - } + set_home(temp.path()); let config = load_config(temp.path()).unwrap(); - unsafe { - env::remove_var("HOME"); - } + unset_home(); assert!(config.enabled); assert_eq!(config.provider, "anthropic"); @@ -839,16 +877,14 @@ openai_compatible_model = "qwen2.5-coder" .unwrap(); unsafe { - env::set_var("HOME", temp.path()); env::remove_var("OPENAI_COMPATIBLE_BASE_URL"); } + set_home(temp.path()); let opts = get_provider_options("openai-compatible"); let model = get_user_model("openai-compatible"); - unsafe { - env::remove_var("HOME"); - } + unset_home(); let opts = opts.expect("base_url should be discovered from config"); assert_eq!( @@ -942,15 +978,11 @@ openai_compatible_model = "gpt-oss:20b-cloud" ) .unwrap(); - unsafe { - env::set_var("HOME", temp.path()); - } + set_home(temp.path()); let resolved = resolve_model_for("openai-compatible", None, None); - unsafe { - env::remove_var("HOME"); - } + unset_home(); assert_eq!(resolved.as_deref(), Some("gpt-oss:20b-cloud")); } diff --git a/tests/snapshots/pulse_site__base_html_page_structure.snap b/tests/snapshots/pulse_site__base_html_page_structure.snap new file mode 100644 index 0000000..f46e8ab --- /dev/null +++ b/tests/snapshots/pulse_site__base_html_page_structure.snap @@ -0,0 +1,92 @@ +--- +source: src/pulse/site.rs +expression: content +--- + + + + + + {% block title %}{{ config.title }}{% endblock title %} + + + + + + + +
+ +
+ {% block content %}{% endblock content %} +
+
+ {% block scripts %}{% endblock scripts %} + + diff --git a/tests/snapshots/pulse_site__base_html_pagefind_integration.snap b/tests/snapshots/pulse_site__base_html_pagefind_integration.snap new file mode 100644 index 0000000..142aae1 --- /dev/null +++ b/tests/snapshots/pulse_site__base_html_pagefind_integration.snap @@ -0,0 +1,8 @@ +--- +source: src/pulse/site.rs +expression: pagefind_section +--- + + + Search + diff --git a/tests/snapshots/pulse_site__home_page_navigation_links.snap b/tests/snapshots/pulse_site__home_page_navigation_links.snap new file mode 100644 index 0000000..377ddaa --- /dev/null +++ b/tests/snapshots/pulse_site__home_page_navigation_links.snap @@ -0,0 +1,24 @@ +--- +source: src/pulse/site.rs +expression: content +--- ++++ +title = "My Project" +sort_by = "weight" ++++ + +# My Project + +Auto-generated codebase documentation powered by [Reflex](https://github.com/reflex-search/reflex). + +## Explore + + diff --git a/tests/snapshots/pulse_site__zola_config_all_surfaces.snap b/tests/snapshots/pulse_site__zola_config_all_surfaces.snap new file mode 100644 index 0000000..8f68f5b --- /dev/null +++ b/tests/snapshots/pulse_site__zola_config_all_surfaces.snap @@ -0,0 +1,31 @@ +--- +source: src/pulse/site.rs +expression: content +--- +# Zola configuration — generated by rfx pulse generate +base_url = "/" +title = "Test Codebase" +description = "Auto-generated codebase documentation" +compile_sass = false +build_search_index = false +generate_feeds = false +minify_html = false + +[markdown] +highlight_code = true +highlight_theme = "base16-ocean-dark" +render_emoji = false +external_links_target_blank = true +smart_punctuation = true + +[slugify] +paths = "safe" + +[extra] +generated_by = "Reflex Pulse" +has_onboard = true +has_glossary = true +has_changelog = true +has_timeline = true +has_map = true +has_explorer = true