Skip to content

Commit 815a216

Browse files
authored
Merge pull request #147 from codeflash-ai/feature/freshness-local-sync-v1
Apply fresh generations without overwriting local work
2 parents e1c72cb + 0b2157d commit 815a216

28 files changed

Lines changed: 11749 additions & 54 deletions

Cargo.lock

Lines changed: 98 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/locality-core/src/conflict.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,27 @@ pub enum ConflictResolution {
3737
Edited(PathBuf),
3838
}
3939

40+
#[derive(Clone, Debug, PartialEq, Eq)]
41+
pub enum ThreeWayTextMerge {
42+
Clean(String),
43+
Conflict(String),
44+
}
45+
46+
/// Deterministic line-oriented three-way merge for portable text payloads.
47+
/// Disjoint edits are combined; overlapping edits are returned with explicit
48+
/// conflict markers so callers can preserve the original local file instead.
49+
pub fn merge_text_with_base(base: &str, local: &str, remote: &str) -> ThreeWayTextMerge {
50+
let merged = render_conflict_marker_body_with_base(base, local, remote);
51+
if merged.contains(CONFLICT_LOCAL_MARKER)
52+
&& merged.contains(CONFLICT_SEPARATOR_MARKER)
53+
&& merged.contains(CONFLICT_REMOTE_MARKER)
54+
{
55+
ThreeWayTextMerge::Conflict(merged)
56+
} else {
57+
ThreeWayTextMerge::Clean(merged)
58+
}
59+
}
60+
4061
pub fn render_inline_conflict_markdown(
4162
local_contents: &str,
4263
remote_document: &CanonicalDocument,

crates/locality-core/src/portable.rs

Lines changed: 115 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,16 @@ use std::fmt::{Display, Formatter};
99
use std::path::{Component, PathBuf};
1010
use std::str::FromStr;
1111

12+
use caseless::Caseless;
1213
use serde::{Deserialize, Deserializer, Serialize};
14+
use unicode_normalization_v16::UnicodeNormalization;
15+
16+
/// Frozen ADR0005 portable filesystem ceilings. Both encodings are bounded so
17+
/// an accepted logical path is materializable on every supported host.
18+
pub const MAX_LOGICAL_PATH_COMPONENT_UTF8_BYTES: usize = 255;
19+
pub const MAX_LOGICAL_PATH_COMPONENT_UTF16_UNITS: usize = 255;
20+
pub const MAX_LOGICAL_PATH_UTF8_BYTES: usize = 1024;
21+
pub const MAX_LOGICAL_PATH_UTF16_UNITS: usize = 1024;
1322

1423
use crate::model::{EntityKind, HydrationState, MountId, RemoteId, TreeEntry};
1524
use crate::planner::{
@@ -136,6 +145,29 @@ impl LogicalPath {
136145
pub fn to_relative_path_buf(&self) -> PathBuf {
137146
PathBuf::from(&self.0)
138147
}
148+
149+
/// ADR0005 portable filesystem collision key.
150+
///
151+
/// Each component uses Unicode 16 full default non-Turkic case folding
152+
/// followed by NFC. Separators remain structural, so this is safe to use
153+
/// for whole-tree collision detection without consulting the host OS.
154+
pub fn portable_collision_key(&self) -> String {
155+
portable_path_collision_key(&self.0)
156+
}
157+
}
158+
159+
fn portable_path_collision_key(value: &str) -> String {
160+
value
161+
.split('/')
162+
.map(|component| {
163+
component
164+
.chars()
165+
.default_case_fold()
166+
.nfc()
167+
.collect::<String>()
168+
})
169+
.collect::<Vec<_>>()
170+
.join("/")
139171
}
140172

141173
impl Display for LogicalPath {
@@ -181,6 +213,9 @@ pub enum LogicalPathError {
181213
ReservedMetadata,
182214
ReservedName(String),
183215
UnsafeCharacter,
216+
NotNfc(String),
217+
ComponentTooLong(String),
218+
PathTooLong,
184219
}
185220

186221
impl Display for LogicalPathError {
@@ -206,6 +241,19 @@ impl Display for LogicalPathError {
206241
Self::UnsafeCharacter => {
207242
formatter.write_str("logical path contains an unsafe character")
208243
}
244+
Self::NotNfc(component) => {
245+
write!(
246+
formatter,
247+
"logical path component is not NFC: `{component}`"
248+
)
249+
}
250+
Self::ComponentTooLong(component) => {
251+
write!(
252+
formatter,
253+
"logical path component exceeds ADR0005 limits: `{component}`"
254+
)
255+
}
256+
Self::PathTooLong => formatter.write_str("logical path exceeds ADR0005 limits"),
209257
}
210258
}
211259
}
@@ -222,17 +270,36 @@ fn validate_logical_path(value: &str) -> Result<(), LogicalPathError> {
222270
if value.contains('\\') {
223271
return Err(LogicalPathError::Backslash);
224272
}
225-
if value.eq_ignore_ascii_case(RESERVED_EXPORT_METADATA_PATH) {
273+
if portable_path_collision_key(value)
274+
== portable_path_collision_key(RESERVED_EXPORT_METADATA_PATH)
275+
{
226276
return Err(LogicalPathError::ReservedMetadata);
227277
}
278+
if value.len() > MAX_LOGICAL_PATH_UTF8_BYTES
279+
|| value.encode_utf16().count() > MAX_LOGICAL_PATH_UTF16_UNITS
280+
{
281+
return Err(LogicalPathError::PathTooLong);
282+
}
228283

229284
for (index, component) in value.split('/').enumerate() {
230285
if component.is_empty() || matches!(component, "." | "..") {
231286
return Err(LogicalPathError::NonNormalizedComponent(
232287
component.to_string(),
233288
));
234289
}
235-
if component.chars().any(char::is_control) || component.contains(':') {
290+
if component.nfc().ne(component.chars()) {
291+
return Err(LogicalPathError::NotNfc(component.to_string()));
292+
}
293+
if component.len() > MAX_LOGICAL_PATH_COMPONENT_UTF8_BYTES
294+
|| component.encode_utf16().count() > MAX_LOGICAL_PATH_COMPONENT_UTF16_UNITS
295+
{
296+
return Err(LogicalPathError::ComponentTooLong(component.to_string()));
297+
}
298+
if component.chars().any(char::is_control)
299+
|| component
300+
.chars()
301+
.any(|character| matches!(character, '<' | '>' | ':' | '"' | '|' | '?' | '*'))
302+
{
236303
if index == 0
237304
&& component.as_bytes().get(1) == Some(&b':')
238305
&& component.as_bytes()[0].is_ascii_alphabetic()
@@ -250,14 +317,20 @@ fn validate_logical_path(value: &str) -> Result<(), LogicalPathError> {
250317
.split_once('.')
251318
.map_or(component, |(stem, _)| stem)
252319
.to_ascii_lowercase();
253-
if matches!(device_name.as_str(), "con" | "prn" | "aux" | "nul")
254-
|| device_name.strip_prefix("com").is_some_and(|suffix| {
255-
matches!(suffix, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9")
256-
})
257-
|| device_name.strip_prefix("lpt").is_some_and(|suffix| {
258-
matches!(suffix, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9")
259-
})
260-
{
320+
if matches!(
321+
device_name.as_str(),
322+
"con" | "prn" | "aux" | "nul" | "clock$" | "conin$" | "conout$"
323+
) || device_name.strip_prefix("com").is_some_and(|suffix| {
324+
matches!(
325+
suffix,
326+
"1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "¹" | "²" | "³"
327+
)
328+
}) || device_name.strip_prefix("lpt").is_some_and(|suffix| {
329+
matches!(
330+
suffix,
331+
"1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "¹" | "²" | "³"
332+
)
333+
}) {
261334
return Err(LogicalPathError::ReservedName(component.to_string()));
262335
}
263336
}
@@ -677,7 +750,13 @@ mod tests {
677750
"safe/./dot.md",
678751
".loc/session.json",
679752
".LOC/SESSION.JSON",
753+
".loc/ſession.json",
680754
"safe/NUL.txt",
755+
"safe/COM¹.txt",
756+
"safe/CONOUT$.log",
757+
"safe/bad?.md",
758+
"safe/bad|name.md",
759+
"safe/e\u{301}.md",
681760
];
682761

683762
for value in cases {
@@ -688,6 +767,32 @@ mod tests {
688767
}
689768
}
690769

770+
#[test]
771+
fn reserved_export_metadata_uses_the_full_adr0005_collision_key() {
772+
assert_eq!(
773+
LogicalPath::new(".loc/ſession.json"),
774+
Err(LogicalPathError::ReservedMetadata)
775+
);
776+
}
777+
778+
#[test]
779+
fn logical_path_enforces_utf8_and_utf16_adr0005_ceilings() {
780+
assert!(LogicalPath::new("a".repeat(255)).is_ok());
781+
assert!(matches!(
782+
LogicalPath::new("a".repeat(256)),
783+
Err(LogicalPathError::ComponentTooLong(_))
784+
));
785+
assert!(matches!(
786+
LogicalPath::new("😀".repeat(128)),
787+
Err(LogicalPathError::ComponentTooLong(_))
788+
));
789+
let long_path = vec!["a".repeat(255); 5].join("/");
790+
assert_eq!(
791+
LogicalPath::new(long_path),
792+
Err(LogicalPathError::PathTooLong)
793+
);
794+
}
795+
691796
#[test]
692797
fn logical_path_deserialization_revalidates_input() {
693798
let error = serde_json::from_str::<LogicalPath>(r#""../escape.md""#)

0 commit comments

Comments
 (0)