@@ -8,6 +8,42 @@ use lash_types::{FileMetadata, FileStatus, TaskFile};
88
99use crate :: error:: { DbError , DbResult } ;
1010
11+ /// Convert a path to a normalized string representation with forward slashes
12+ ///
13+ /// This ensures consistent path representation across all platforms:
14+ /// - Windows paths with backslashes are converted to forward slashes
15+ /// - Unix paths remain unchanged
16+ /// - Enables cross-platform database compatibility
17+ ///
18+ /// # Example
19+ ///
20+ /// ```
21+ /// # use std::path::Path;
22+ /// # use lash_db::repository::normalize_path_for_db;
23+ /// // On Windows: "features\\auth.md" -> "features/auth.md"
24+ /// // On Unix: "features/auth.md" -> "features/auth.md"
25+ /// let path = Path::new("features").join("auth.md");
26+ /// let normalized = normalize_path_for_db(&path);
27+ /// assert_eq!(normalized, "features/auth.md");
28+ /// ```
29+ #[ must_use]
30+ pub fn normalize_path_for_db ( path : & Path ) -> String {
31+ // Convert path to string using lossy conversion (handles invalid UTF-8)
32+ let path_str = path. to_string_lossy ( ) ;
33+
34+ // On Windows, replace backslashes with forward slashes
35+ // On Unix, this is a no-op since paths already use forward slashes
36+ #[ cfg( windows) ]
37+ {
38+ path_str. replace ( '\\' , "/" )
39+ }
40+
41+ #[ cfg( not( windows) ) ]
42+ {
43+ path_str. to_string ( )
44+ }
45+ }
46+
1147/// A file record from the database
1248///
1349/// Represents a row from the files table, including all metadata.
@@ -112,7 +148,7 @@ impl<'conn> FileRepository<'conn> {
112148 "INSERT INTO files (path, file_id, title, hash, mtime, status, metadata)
113149 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)" ,
114150 (
115- file. path . to_string_lossy ( ) . as_ref ( ) ,
151+ normalize_path_for_db ( & file. path ) ,
116152 & file. id ,
117153 & file. title ,
118154 & file. hash ,
@@ -155,7 +191,7 @@ impl<'conn> FileRepository<'conn> {
155191 mtime,
156192 status. as_str ( ) ,
157193 metadata_json,
158- file. path . to_string_lossy ( ) . as_ref ( ) ,
194+ normalize_path_for_db ( & file. path ) ,
159195 ) ,
160196 ) ?;
161197
@@ -178,7 +214,7 @@ impl<'conn> FileRepository<'conn> {
178214 pub fn delete ( & self , path : & Path ) -> DbResult < ( ) > {
179215 self . conn . execute (
180216 "DELETE FROM files WHERE path = ?1" ,
181- [ path . to_string_lossy ( ) . as_ref ( ) ] ,
217+ [ normalize_path_for_db ( path ) ] ,
182218 ) ?;
183219 Ok ( ( ) )
184220 }
@@ -193,7 +229,7 @@ impl<'conn> FileRepository<'conn> {
193229 . query_row (
194230 "SELECT id, path, file_id, title, hash, mtime, status, metadata, indexed_at
195231 FROM files WHERE path = ?1" ,
196- [ path . to_string_lossy ( ) . as_ref ( ) ] ,
232+ [ normalize_path_for_db ( path ) ] ,
197233 |row| {
198234 let metadata_json: String = row. get ( 7 ) ?;
199235 let metadata: FileMetadata =
@@ -377,7 +413,7 @@ impl<'conn> FileRepository<'conn> {
377413 "INSERT INTO files (path, file_id, title, hash, mtime, status, metadata)
378414 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)" ,
379415 (
380- file. path . to_string_lossy ( ) . as_ref ( ) ,
416+ normalize_path_for_db ( & file. path ) ,
381417 & file. id ,
382418 & file. title ,
383419 & file. hash ,
@@ -422,6 +458,8 @@ impl<'conn> FileRepository<'conn> {
422458 . map_err ( |e| DbError :: Other ( format ! ( "Invalid mtime: {e}" ) ) ) ?
423459 . as_secs ( ) as i64 ;
424460
461+ let normalized_path = normalize_path_for_db ( & file. path ) ;
462+
425463 tx. execute (
426464 "INSERT INTO files (path, file_id, title, hash, mtime, status, metadata)
427465 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
@@ -433,7 +471,7 @@ impl<'conn> FileRepository<'conn> {
433471 status = excluded.status,
434472 metadata = excluded.metadata" ,
435473 (
436- file . path . to_string_lossy ( ) . as_ref ( ) ,
474+ & normalized_path ,
437475 & file. id ,
438476 & file. title ,
439477 & file. hash ,
@@ -446,7 +484,7 @@ impl<'conn> FileRepository<'conn> {
446484 // Get the file's database ID
447485 let file_id: i64 = tx. query_row (
448486 "SELECT id FROM files WHERE path = ?1" ,
449- [ file . path . to_string_lossy ( ) . as_ref ( ) ] ,
487+ [ & normalized_path ] ,
450488 |row| row. get ( 0 ) ,
451489 ) ?;
452490
@@ -740,4 +778,68 @@ mod tests {
740778 assert_eq ! ( all_files. len( ) , 1 ) ;
741779 assert_eq ! ( all_files[ 0 ] . path, PathBuf :: from( "duplicate.md" ) ) ;
742780 }
781+
782+ #[ test]
783+ fn test_path_normalization_cross_platform ( ) {
784+ // Test that paths are stored with forward slashes regardless of platform
785+ let temp_db = NamedTempFile :: new ( ) . unwrap ( ) ;
786+ let conn = init_database ( temp_db. path ( ) ) . unwrap ( ) ;
787+ let repo = FileRepository :: new ( & conn) ;
788+
789+ // Create a file with a subdirectory path
790+ // On Windows: PathBuf::from("features").join("auth.md") creates "features\auth.md"
791+ // On Unix: PathBuf::from("features").join("auth.md") creates "features/auth.md"
792+ let path = PathBuf :: from ( "features" ) . join ( "auth.md" ) ;
793+ let mut file = create_test_file ( "test.md" ) ;
794+ file. path = path. clone ( ) ;
795+
796+ repo. insert ( & file) . unwrap ( ) ;
797+
798+ // Verify the path is stored with forward slashes in the database
799+ let stored_path: String = conn
800+ . query_row (
801+ "SELECT path FROM files WHERE file_id = ?1" ,
802+ [ & file. id ] ,
803+ |row| row. get ( 0 ) ,
804+ )
805+ . unwrap ( ) ;
806+
807+ // Path should always be stored with forward slashes, regardless of platform
808+ assert_eq ! ( stored_path, "features/auth.md" ) ;
809+
810+ // Verify we can retrieve the file using the normalized path
811+ let retrieved = repo. get_by_path ( & path) . unwrap ( ) ;
812+ assert ! ( retrieved. is_some( ) ) ;
813+
814+ let retrieved = retrieved. unwrap ( ) ;
815+ assert_eq ! ( retrieved. file_id, "test.md" ) ;
816+ }
817+
818+ #[ test]
819+ fn test_normalize_path_for_db_unix_style ( ) {
820+ // Test that Unix-style paths remain unchanged
821+ let path = Path :: new ( "features/auth.md" ) ;
822+ let normalized = normalize_path_for_db ( path) ;
823+ assert_eq ! ( normalized, "features/auth.md" ) ;
824+ }
825+
826+ #[ test]
827+ fn test_normalize_path_for_db_windows_style ( ) {
828+ // Test that Windows-style paths are converted to Unix-style
829+ // This test works on all platforms - we're just testing the string replacement logic
830+ #[ cfg( windows) ]
831+ {
832+ let path = PathBuf :: from ( "features" ) . join ( "auth.md" ) ;
833+ let normalized = normalize_path_for_db ( & path) ;
834+ assert_eq ! ( normalized, "features/auth.md" ) ;
835+ }
836+
837+ #[ cfg( not( windows) ) ]
838+ {
839+ // On Unix, PathBuf::from("features").join("auth.md") creates "features/auth.md"
840+ let path = PathBuf :: from ( "features" ) . join ( "auth.md" ) ;
841+ let normalized = normalize_path_for_db ( & path) ;
842+ assert_eq ! ( normalized, "features/auth.md" ) ;
843+ }
844+ }
743845}
0 commit comments