path_normalize.rs (5540B)
1 //! Path normalization for JSONL source-data. 2 //! 3 //! When storing JSONL lines in nostr events, absolute paths are converted to 4 //! relative (using the session's `cwd` as base). On reconstruction, relative 5 //! paths are re-expanded using the local machine's working directory. 6 //! 7 //! This operates on the raw JSON string via string replacement — paths can 8 //! appear anywhere in tool inputs/outputs, so structural replacement would 9 //! miss nested occurrences. 10 11 /// Replace all occurrences of `cwd` prefix in absolute paths with relative paths. 12 /// 13 /// Not currently used (Phase 1 stores raw paths), kept for future Phase 2. 14 #[allow(dead_code)] 15 /// 16 /// For example, with cwd = "/Users/jb55/dev/notedeck": 17 /// "/Users/jb55/dev/notedeck/src/main.rs" → "src/main.rs" 18 /// "/Users/jb55/dev/notedeck" → "." 19 pub fn normalize_paths(json: &str, cwd: &str) -> String { 20 if cwd.is_empty() { 21 return json.to_string(); 22 } 23 24 // Ensure cwd doesn't have a trailing slash for consistent matching 25 let cwd = cwd.strip_suffix('/').unwrap_or(cwd); 26 27 // Replace "cwd/" prefix first (subpaths), then bare "cwd" (exact match) 28 let with_slash = format!("{}/", cwd); 29 let result = json.replace(&with_slash, ""); 30 31 // Replace bare cwd (e.g. the cwd field itself) with "." 32 result.replace(cwd, ".") 33 } 34 35 /// Re-expand relative paths back to absolute using the given local cwd. 36 /// 37 /// Reverses `normalize_paths`: the cwd field "." becomes the local cwd, 38 /// and relative paths get the cwd prefix prepended. 39 /// 40 /// Note: This is not perfectly inverse — it will also expand any unrelated 41 /// "." occurrences that happen to match. In practice, the cwd field is the 42 /// main target, and relative paths in tool inputs/outputs are the rest. 43 /// 44 /// Not currently used (Phase 1 stores raw paths), kept for future Phase 2. 45 #[allow(dead_code)] 46 pub fn denormalize_paths(json: &str, local_cwd: &str) -> String { 47 if local_cwd.is_empty() { 48 return json.to_string(); 49 } 50 51 let local_cwd = local_cwd.strip_suffix('/').unwrap_or(local_cwd); 52 53 // We need to be careful about ordering here. We want to: 54 // 1. Replace "." (bare cwd reference) with the local cwd 55 // 2. Re-expand relative paths that were stripped of the cwd prefix 56 // 57 // But since normalized JSON has paths like "src/main.rs" (no prefix), 58 // we can't blindly prefix all bare paths. Instead, we reverse the 59 // exact transformations that normalize_paths applied: 60 // 61 // The normalize step replaced: 62 // "{cwd}/" → "" (paths become relative) 63 // "{cwd}" → "." (bare cwd references) 64 // 65 // So to reverse, we need context-aware replacement. The safest approach 66 // is to look for patterns that were likely produced by normalization: 67 // - JSON string values that are exactly "." → local_cwd 68 // - Relative paths in known field positions 69 // 70 // For now, we do simple string replacement which handles the most 71 // common case (the "cwd" field). Full path reconstruction for tool 72 // inputs/outputs would need the original field structure. 73 74 // Replace "\"cwd\":\".\"" with the local cwd 75 let result = json.replace("\"cwd\":\".\"", &format!("\"cwd\":\"{}\"", local_cwd)); 76 77 result 78 } 79 80 #[cfg(test)] 81 mod tests { 82 use super::*; 83 84 #[test] 85 fn test_normalize_absolute_paths() { 86 let json = 87 r#"{"cwd":"/Users/jb55/dev/notedeck","file":"/Users/jb55/dev/notedeck/src/main.rs"}"#; 88 let normalized = normalize_paths(json, "/Users/jb55/dev/notedeck"); 89 assert_eq!(normalized, r#"{"cwd":".","file":"src/main.rs"}"#); 90 } 91 92 #[test] 93 fn test_normalize_with_trailing_slash() { 94 // cwd with trailing slash is stripped; the cwd value in JSON 95 // still contains the trailing slash so it becomes "" + "/" = "/" 96 // after replacing the base. In practice JSONL cwd values don't 97 // have trailing slashes. 98 let json = r#"{"cwd":"/tmp/project","file":"/tmp/project/lib.rs"}"#; 99 let normalized = normalize_paths(json, "/tmp/project/"); 100 assert_eq!(normalized, r#"{"cwd":".","file":"lib.rs"}"#); 101 } 102 103 #[test] 104 fn test_normalize_empty_cwd() { 105 let json = r#"{"file":"/some/path"}"#; 106 let normalized = normalize_paths(json, ""); 107 assert_eq!(normalized, json); 108 } 109 110 #[test] 111 fn test_normalize_no_matching_paths() { 112 let json = r#"{"file":"/other/path/file.rs"}"#; 113 let normalized = normalize_paths(json, "/Users/jb55/dev/notedeck"); 114 assert_eq!(normalized, json); 115 } 116 117 #[test] 118 fn test_normalize_multiple_occurrences() { 119 let json = 120 r#"{"old":"/Users/jb55/dev/notedeck/a.rs","new":"/Users/jb55/dev/notedeck/b.rs"}"#; 121 let normalized = normalize_paths(json, "/Users/jb55/dev/notedeck"); 122 assert_eq!(normalized, r#"{"old":"a.rs","new":"b.rs"}"#); 123 } 124 125 #[test] 126 fn test_denormalize_cwd_field() { 127 let json = r#"{"cwd":"."}"#; 128 let denormalized = denormalize_paths(json, "/Users/jb55/dev/notedeck"); 129 assert_eq!(denormalized, r#"{"cwd":"/Users/jb55/dev/notedeck"}"#); 130 } 131 132 #[test] 133 fn test_normalize_roundtrip_cwd() { 134 let original_cwd = "/Users/jb55/dev/notedeck"; 135 let json = r#"{"cwd":"/Users/jb55/dev/notedeck"}"#; 136 let normalized = normalize_paths(json, original_cwd); 137 assert_eq!(normalized, r#"{"cwd":"."}"#); 138 let denormalized = denormalize_paths(&normalized, original_cwd); 139 assert_eq!(denormalized, json); 140 } 141 }