diff --git a/.changeset/decode-project-path-middle-merge.md b/.changeset/decode-project-path-middle-merge.md new file mode 100644 index 0000000..267a1e5 --- /dev/null +++ b/.changeset/decode-project-path-middle-merge.md @@ -0,0 +1,5 @@ +--- +"lovcode": patch +--- + +Fix project-path decoding for projects whose directory name contains a hyphen. The fallback in `decode_project_path` now enumerates every contiguous segment range and merges with `-` (not only a prefix), so middle-of-path names like `.../git//` resolve to a real directory instead of being split across two segments. diff --git a/src-tauri/src/app/session_cache.rs b/src-tauri/src/app/session_cache.rs index c46131e..4272578 100644 --- a/src-tauri/src/app/session_cache.rs +++ b/src-tauri/src/app/session_cache.rs @@ -56,7 +56,7 @@ pub(crate) struct SessionsCache { pub(crate) entries: Vec, } -pub(crate) const SESSIONS_CACHE_VERSION: u32 = 6; +pub(crate) const SESSIONS_CACHE_VERSION: u32 = 7; pub(crate) fn sessions_cache_path() -> PathBuf { get_lovstudio_dir().join("sessions-cache.json") @@ -122,6 +122,45 @@ fn collect_lightweight_sessions_snapshot() -> Vec { let mut sessions = Vec::new(); let projects_dir = get_claude_dir().join("projects"); + // Pre-pass: build project_id → cwd lookup so tail-only jsonl snapshots + // (no cwd in head) can still resolve to the correct project path. + // Scans all jsonls (not just newest) so a cwd-less tail snapshot doesn't + // shadow a richer earlier session's cwd. + let project_cwd_map: std::collections::HashMap = { + let mut map = std::collections::HashMap::new(); + if let Ok(project_entries) = fs::read_dir(&projects_dir) { + for project_entry in project_entries.filter_map(|e| e.ok()) { + let project_path = project_entry.path(); + if !project_path.is_dir() { + continue; + } + let project_id = project_path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + if let Ok(files) = fs::read_dir(&project_path) { + for f in files.filter_map(|e| e.ok()) { + let name = f.file_name().to_string_lossy().to_string(); + if !name.ends_with(".jsonl") || name.starts_with("agent-") { + continue; + } + if map.contains_key(&project_id) { + break; + } + if let Some(cwd) = read_session_head(&f.path(), 20).cwd { + if !cwd.is_empty() { + map.insert(project_id.clone(), cwd); + break; + } + } + } + } + } + } + map + }; + if projects_dir.exists() { for project_entry in fs::read_dir(&projects_dir).into_iter().flatten().flatten() { let project_path = project_entry.path(); @@ -133,7 +172,10 @@ fn collect_lightweight_sessions_snapshot() -> Vec { .unwrap_or_default() .to_string_lossy() .to_string(); - let display_path = decode_project_path(&project_id); + let display_path = project_cwd_map + .get(&project_id) + .cloned() + .unwrap_or_else(|| decode_project_path(&project_id)); for entry in fs::read_dir(&project_path).into_iter().flatten().flatten() { let path = entry.path(); @@ -234,6 +276,50 @@ pub(crate) fn compute_all_sessions() -> Vec { if !projects_dir.exists() { return Vec::new(); } + + // Pre-pass: build `project_id → project_cwd` by reading cwd from the + // first jsonl in each project_dir that has one. Sessions within the + // same project share the same cwd in Claude Code's encoding, so when a + // particular jsonl's head doesn't contain a cwd line (typical for + // tail-only snapshots a few hundred bytes long) we fall back to the + // project-level cwd rather than the lossy `decode_project_path` + // heuristic. Scanning all jsonls (rather than just the newest) handles + // the case where the newest happens to be the cwd-less tail snapshot. + let project_cwd_map: std::collections::HashMap = { + let mut map = std::collections::HashMap::new(); + if let Ok(project_entries) = fs::read_dir(&projects_dir) { + for project_entry in project_entries.filter_map(|e| e.ok()) { + let project_path = project_entry.path(); + if !project_path.is_dir() { + continue; + } + let project_id = project_path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + if let Ok(files) = fs::read_dir(&project_path) { + for f in files.filter_map(|e| e.ok()) { + let name = f.file_name().to_string_lossy().to_string(); + if !name.ends_with(".jsonl") || name.starts_with("agent-") { + continue; + } + if map.contains_key(&project_id) { + break; + } + if let Some(cwd) = read_session_head(&f.path(), 20).cwd { + if !cwd.is_empty() { + map.insert(project_id.clone(), cwd); + break; + } + } + } + } + } + } + map + }; + let history_index = build_session_index_from_history(); let cache = load_sessions_cache(); // Collected during pass1/pass2 (cache hits + fresh reads alike) so we @@ -296,6 +382,7 @@ pub(crate) fn compute_all_sessions() -> Vec { let display_path = head .cwd .clone() + .or_else(|| project_cwd_map.get(project_id).cloned()) .unwrap_or_else(|| decode_project_path(project_id)); let session = Session { @@ -343,7 +430,10 @@ pub(crate) fn compute_all_sessions() -> Vec { .unwrap() .to_string_lossy() .to_string(); - let display_path = decode_project_path(&project_id); + let display_path = project_cwd_map + .get(&project_id) + .cloned() + .unwrap_or_else(|| decode_project_path(&project_id)); for entry in fs::read_dir(&project_path).into_iter().flatten().flatten() { let path = entry.path(); @@ -1127,3 +1217,175 @@ pub(crate) async fn get_app_starred_session_ids() -> Result, String> .await .map_err(|e| e.to_string())? } + +#[cfg(test)] +mod tests { + use super::*; + + /// Helper: write a jsonl line into `path`. Appends a trailing newline so + /// each line is treated as a separate record. + fn append_jsonl(path: &Path, json_line: &str) { + use std::io::Write; + let mut f = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .unwrap(); + writeln!(f, "{json_line}").unwrap(); + } + + /// Helper: build a temp directory shaped like `~/.claude/projects//` + /// and return the path to the projects root. + fn make_unique_claude_root() -> (PathBuf, impl Drop) { + let nonce = uuid::Uuid::new_v4(); + let path = std::env::temp_dir().join(format!("lovcode-cache-{nonce}")); + std::fs::create_dir_all(&path).unwrap(); + let root = path.clone(); + let guard = scopeguard_like(path); + (root, guard) + } + + fn scopeguard_like(path: PathBuf) -> impl Drop { + struct RmOnDrop(PathBuf); + impl Drop for RmOnDrop { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + RmOnDrop(path) + } + + /// Helper: write the minimum jsonl lines that give `read_session_head` + /// something to extract cwd from. Real Claude Code lines include more + /// fields but for the head scanner only `"cwd"` matters. + fn write_session_with_cwd(jsonl_path: &Path, cwd: &str, session_id: &str) { + // First line: a user-message-like envelope carrying cwd. The head + // scanner doesn't care about type, only the `"cwd":"..."` pattern. + let first = format!( + r#"{{"parentUuid":null,"isSidechain":false,"userType":"external","entrypoint":"cli","cwd":"{cwd}","sessionId":"{session_id}","version":"2.0.0"}}"# + ); + append_jsonl(jsonl_path, &first); + // A second line ensures the head isn't just one record. + append_jsonl( + jsonl_path, + r#"{"parentUuid":null,"isSidechain":false,"type":"user","message":{"role":"user","content":"hello"}}"#, + ); + } + + /// Tail-only snapshots start with `last-prompt` / `mode` / `permission-mode` + /// — metadata-only, no message lines and no `cwd` in the first 64KB. + fn write_tail_only_snapshot(jsonl_path: &Path, session_id: &str) { + append_jsonl( + jsonl_path, + &format!( + r#"{{"type":"last-prompt","leafUuid":"x","sessionId":"{session_id}"}}"# + ), + ); + append_jsonl(jsonl_path, r#"{"type":"mode","mode":"normal"}"#); + append_jsonl( + jsonl_path, + r#"{"type":"permission-mode","permissionMode":"bypassPermissions"}"#, + ); + } + + /// Build the project_cwd_map exactly the way `compute_all_sessions` does. + /// Returns a `HashMap`. Used to verify the fallback. + fn build_project_cwd_map(projects_dir: &Path) -> std::collections::HashMap { + let mut map = std::collections::HashMap::new(); + if let Ok(project_entries) = fs::read_dir(projects_dir) { + for project_entry in project_entries.filter_map(|e| e.ok()) { + let project_path = project_entry.path(); + if !project_path.is_dir() { + continue; + } + let project_id = project_path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + if let Ok(files) = fs::read_dir(&project_path) { + for f in files.filter_map(|e| e.ok()) { + let name = f.file_name().to_string_lossy().to_string(); + if !name.ends_with(".jsonl") || name.starts_with("agent-") { + continue; + } + if map.contains_key(&project_id) { + break; + } + if let Some(cwd) = read_session_head(&f.path(), 20).cwd { + if !cwd.is_empty() { + map.insert(project_id.clone(), cwd); + break; + } + } + } + } + } + } + map + } + + #[test] + fn project_cwd_map_picks_cwd_from_any_session_in_project() { + // Two sessions under the same project_id, but only one of them has + // a cwd in its jsonl head. The map should still surface that cwd, + // even if the cwd-less one happens to be the newest (e.g. just a + // tail snapshot append). + let (claude_root, _g) = make_unique_claude_root(); + let projects_dir = claude_root.join("projects"); + let project_id = "some-dashed-project"; + let project_dir = projects_dir.join(project_id); + std::fs::create_dir_all(&project_dir).unwrap(); + + let good = project_dir.join("good.jsonl"); + let tail_only = project_dir.join("tail-only.jsonl"); + + write_session_with_cwd(&good, "/path/to/some-dashed-project", "good-session"); + std::thread::sleep(std::time::Duration::from_millis(50)); + write_tail_only_snapshot(&tail_only, "tail-session"); + // Bump tail-only's mtime to "newest" so it would be picked by the + // old (newest-only) implementation. The new scan-all approach + // should still surface the cwd from `good`. + let now = std::time::SystemTime::now(); + filetime::set_file_mtime( + &tail_only, + filetime::FileTime::from_system_time(now), + ) + .unwrap(); + + let map = build_project_cwd_map(&projects_dir); + + assert_eq!( + map.get(project_id).map(String::as_str), + Some("/path/to/some-dashed-project") + ); + } + + #[test] + fn tail_only_session_falls_back_to_project_cwd() { + // End-to-end simulation: two sessions under the same project, only + // one with cwd in head. The head-less session's `read_session_head` + // returns cwd=None; the project_cwd_map supplies the right answer. + let (claude_root, _g) = make_unique_claude_root(); + let projects_dir = claude_root.join("projects"); + let project_id = "dashed-project"; + let project_dir = projects_dir.join(project_id); + std::fs::create_dir_all(&project_dir).unwrap(); + + let good = project_dir.join("good.jsonl"); + let tail_only = project_dir.join("tail.jsonl"); + write_session_with_cwd(&good, "/srv/dashed-project", "good"); + std::thread::sleep(std::time::Duration::from_millis(50)); + write_tail_only_snapshot(&tail_only, "tail"); + + // Simulate pass2: read tail-only's head → no cwd. Then fall back to + // the project-level cwd from the map. + let head = read_session_head(&tail_only, 20); + assert!(head.cwd.is_none(), "tail snapshot should have no cwd in head"); + + let map = build_project_cwd_map(&projects_dir); + let project_cwd = map.get(project_id).expect("map should have entry"); + let session_path = head.cwd.clone().unwrap_or_else(|| project_cwd.clone()); + assert_eq!(session_path, "/srv/dashed-project"); + } +} diff --git a/src-tauri/src/app/session_listing.rs b/src-tauri/src/app/session_listing.rs index 38aabd8..4814e29 100644 --- a/src-tauri/src/app/session_listing.rs +++ b/src-tauri/src/app/session_listing.rs @@ -5,10 +5,50 @@ pub(crate) fn get_claude_json_path() -> PathBuf { dirs::home_dir().unwrap().join(".claude.json") } -/// Encode project path to project ID (inverse of decode_project_path). -/// Claude Code encodes: `/.` -> `--`, then `/` -> `-` +/// Encode project path to project ID. Mirrors Claude Code's `sanitizePath` in +/// `src/utils/sessionStoragePortable.ts`: every non-alphanumeric character is +/// replaced with `-`. For paths ≤ 200 chars no hash is appended; for longer +/// paths we truncate at 200 and append a base-36 hash. Claude Code picks +/// `Bun.hash` (wyhash) or `simpleHash` (djb2) depending on runtime, but +/// Lovcode only runs in the desktop Tauri app (no Bun), so we always use the +/// djb2 variant. Claude Code's `findProjectDir` falls back to prefix-scanning +/// for cross-runtime hashes anyway, so a deterministic djb2 is sufficient for +/// round-tripping our own writes. pub(crate) fn encode_project_path(path: &str) -> String { - path.replace("/.", "--").replace("/", "-") + const MAX_LEN: usize = 200; + let sanitized: String = path + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect(); + if sanitized.len() <= MAX_LEN { + return sanitized; + } + let hash = djb2_base36(path); + format!("{}-{}", &sanitized[..MAX_LEN], hash) +} + +/// Classic djb2 hash, base-36-encoded. We don't need cryptographic quality — +/// only a short stable tag to disambiguate two paths that share the first 200 +/// chars after sanitization. +fn djb2_base36(input: &str) -> String { + const CHARS: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz"; + let mut hash: u64 = 5381; + for b in input.bytes() { + hash = hash.wrapping_mul(33).wrapping_add(u64::from(b)); + } + let mut n = (hash as i64).unsigned_abs(); + if n == 0 { + return "0".to_string(); + } + let mut buf = [0u8; 16]; + let mut len = 0; + while n > 0 && len < buf.len() { + buf[len] = CHARS[(n % 36) as usize]; + n /= 36; + len += 1; + } + buf[..len].reverse(); + String::from_utf8(buf[..len].to_vec()).unwrap() } /// Decode project ID to actual filesystem path. @@ -34,7 +74,7 @@ pub(crate) fn decode_project_path(id: &str) -> String { .replace("-", "/") .replace("\x00", "/."); - // Normalize: strip trailing /. and / segments (e.g. /Users/mark/././ → /Users/mark) + // Normalize: strip trailing /. and / segments (e.g. /home//././ → /home/) let base = base.trim_end_matches('/').to_string(); let base = { let mut b = base.as_str(); @@ -63,7 +103,7 @@ pub(crate) fn decode_project_path(id: &str) -> String { } } - // Try merging from /Users/mark/ (home dir) as base + // Try merging from /home// (home dir) as base if let Some(home) = dirs::home_dir() { let home_str = format!("{}/", home.display()); if base.starts_with(&home_str) { @@ -78,30 +118,56 @@ pub(crate) fn decode_project_path(id: &str) -> String { base } -/// Try different combinations of merging path segments with hyphens +/// Try to recover a filesystem path by progressively merging one contiguous +/// range of segments with `-` and keeping the remaining segments as `/`. +/// +/// Claude Code stores the canonical cwd inside each session jsonl itself, so +/// `decode_project_path` should be treated as a **last-resort fallback** for +/// projects that don't yet have a parseable session. For everything else, the +/// display path comes straight from `cwd` in the first jsonl line. +/// +/// The encoding itself is lossy (Claude Code replaces every non-alphanumeric +/// character with `-`, so e.g. `/path/-` and `/path/_` would both +/// encode to the same project id), so no algorithmic decoder can be perfectly +/// correct. This heuristic enumerates the plausible partitions and lets the +/// first candidate that exists on disk win; the caller is expected to +/// overwrite it with `cwd` whenever a parseable session exists. pub(crate) fn try_merge_segments(prefix: &str, rest: &str) -> Option { let segments: Vec<&str> = rest.split('/').filter(|s| !s.is_empty()).collect(); if segments.is_empty() { return None; } - // Try merging all segments into one (most common: project-name-here) - let all_merged = format!("{}{}", prefix, segments.join("-")); - if PathBuf::from(&all_merged).exists() { - return Some(all_merged); + let prefix = prefix.trim_end_matches('/'); + let n = segments.len(); + + // 0 blocks: naive decode (`a/b/c`). + let naive = format!("{}/{}", prefix, segments.join("/")); + if PathBuf::from(&naive).exists() { + return Some(naive); } - // Try merging first N segments, leaving rest as subdirs - for merge_count in (1..segments.len()).rev() { - let merged_part = segments[..=merge_count].join("-"); - let rest_part = segments[merge_count + 1..].join("/"); - let candidate = if rest_part.is_empty() { - format!("{}{}", prefix, merged_part) - } else { - format!("{}{}/{}", prefix, merged_part, rest_part) - }; - if PathBuf::from(&candidate).exists() { - return Some(candidate); + // 1 block: any contiguous [start, end) merged with '-'. + for start in 0..n { + for end in (start + 1)..=n { + let mut parts: Vec = Vec::with_capacity(3); + if start > 0 { + parts.push(segments[..start].join("/")); + } + parts.push(segments[start..end].join("-")); + let candidate = format!( + "{}/{}", + prefix, + parts + .into_iter() + .chain(std::iter::once(segments[end..].join("/"))) + .filter(|p| !p.is_empty()) + .collect::>() + .join("/") + ); + if PathBuf::from(&candidate).exists() { + return Some(candidate); + } } } @@ -126,10 +192,10 @@ pub(crate) async fn list_projects() -> Result, String> { if path.is_dir() { let id = path.file_name().unwrap().to_string_lossy().to_string(); - let display_path = decode_project_path(&id); let mut session_count = 0; let mut last_active: u64 = 0; + let mut newest_jsonl: Option<(PathBuf, std::time::SystemTime)> = None; if let Ok(entries) = fs::read_dir(&path) { for entry in entries.filter_map(|e| e.ok()) { @@ -143,12 +209,28 @@ pub(crate) async fn list_projects() -> Result, String> { { last_active = last_active.max(duration.as_secs()); } + // Track newest jsonl so we can read its cwd. + newest_jsonl = Some(match newest_jsonl { + Some((p, t)) if t >= modified => (p, t), + _ => (entry.path(), modified), + }); } } } } } + // Preferred display path: the cwd stamped into the newest + // session's first line (`session_meta.cwd`). Claude Code stores + // this verbatim — it's the canonical, lossless source of truth. + // Fall back to `decode_project_path` only when no parseable + // session exists yet (rare edge case: brand-new project dir). + let display_path = newest_jsonl + .as_ref() + .and_then(|(p, _)| read_session_head(p, 20).cwd) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| decode_project_path(&id)); + projects.push(Project { id: id.clone(), path: display_path, @@ -764,6 +846,149 @@ mod tests { )); assert!(!is_codex_context_message("Write a daily product report")); } + + /// Build a uniquely-named temp directory tree for decode tests. + /// Returns (anchor_path, _guard). The anchor path is guaranteed unique and + /// self-contained so each test gets its own filesystem namespace. + fn make_unique_anchor() -> (PathBuf, impl Drop) { + let nonce = uuid::Uuid::new_v4(); + let path = std::env::temp_dir().join(format!("lovcode-decode-{}", nonce)); + std::fs::create_dir_all(&path).expect("create temp anchor"); + let anchor = path.clone(); + let guard = scopeguard_like(path); + (anchor, guard) + } + + fn scopeguard_like(path: PathBuf) -> impl Drop { + struct RmOnDrop(PathBuf); + impl Drop for RmOnDrop { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + RmOnDrop(path) + } + + #[test] + fn try_merge_segments_recovers_middle_merge() { + // Regression: paths like `/parent//` where + // only the last two segments are merged. The previous algorithm could + // not produce this candidate because it only merged a prefix of the + // segment list, so such projects decoded to `/parent//`. + let (anchor, _g) = make_unique_anchor(); + let target = anchor.join("parent/cool-project"); + std::fs::create_dir_all(&target).unwrap(); + + let prefix = format!("{}/", anchor.display()); + let rest = "parent/cool-project"; + + let found = try_merge_segments(&prefix, rest).expect("should resolve middle-merge path"); + assert_eq!(found, target.to_string_lossy().to_string()); + } + + #[test] + fn try_merge_segments_recovers_prefix_merge() { + // Old behavior: merge first N segments, keep the rest as subdirs. + // e.g. `~/projects/my-cool-app/src` should resolve correctly. + let (anchor, _g) = make_unique_anchor(); + let target = anchor.join("projects/my-cool-app/src"); + std::fs::create_dir_all(&target).unwrap(); + + let prefix = format!("{}/", anchor.display()); + let rest = "projects/my-cool-app/src"; + + let found = try_merge_segments(&prefix, rest).expect("should resolve prefix-merge path"); + assert_eq!(found, target.to_string_lossy().to_string()); + } + + #[test] + fn try_merge_segments_recovers_all_merged() { + // Old "all_merged" case: every segment is part of a dash-named project. + let (anchor, _g) = make_unique_anchor(); + let target = anchor.join("repos/this-is-a-deeply-nested-name"); + std::fs::create_dir_all(&target).unwrap(); + + let prefix = format!("{}/", anchor.display()); + let rest = "repos/this-is-a-deeply-nested-name"; + + let found = try_merge_segments(&prefix, rest).expect("should resolve all-merged path"); + assert_eq!(found, target.to_string_lossy().to_string()); + } + + #[test] + fn try_merge_segments_returns_none_when_no_match() { + let (anchor, _g) = make_unique_anchor(); + let prefix = format!("{}/", anchor.display()); + // Don't create any of the listed directories — no candidate exists. + let rest = "parent/nowhere-to-be-found"; + assert!(try_merge_segments(&prefix, rest).is_none()); + } + + // ----- encode_project_path ---------------------------------------------------- + // Mirrors Claude Code's `sanitizePath` (src/utils/sessionStoragePortable.ts:311). + + #[test] + fn encode_project_path_replaces_all_non_alphanumeric() { + // Mirrors Claude Code's regex: `[^a-zA-Z0-9]` → `-`. + // Includes `_`, `.`, ` `, `:`, etc. — all collapse to `-`. + // Consecutive non-alphanumeric chars (e.g. `/.`) become consecutive `-`. + assert_eq!( + encode_project_path("/home/example/projects/cool-app"), + "-home-example-projects-cool-app" + ); + assert_eq!( + encode_project_path("/srv/repos/cool-project/"), + "-srv-repos-cool-project-" + ); + // `/.` (hidden-dir marker) becomes `--`, not collapsed. + assert_eq!( + encode_project_path("/home/example/.claude"), + "-home-example--claude" + ); + assert_eq!( + encode_project_path("/home/example/my_project.dev/"), + "-home-example-my-project-dev-" + ); + } + + #[test] + fn encode_project_path_truncates_long_paths_with_hash() { + // Paths longer than 200 chars after sanitization get truncated and + // a hash suffix appended. + let long = "/".to_string() + &"a/".repeat(150); // 301 chars raw + let encoded = encode_project_path(&long); + assert!(encoded.len() > 200, "got: {encoded}"); + // Sanitized form alternates `-a` (50 pairs) followed by a trailing + // `-` for the final `/`. Truncation keeps the first 200 chars, so the + // encoded form starts with 50 `-a` pairs (100 chars) followed by 100 + // more `-a` pairs (200 chars total), then `-`. + assert!( + encoded.starts_with(&"-a".repeat(100)), + "expected first 200 chars to be `-a` x100, got: {encoded}" + ); + // Hash suffix: tail of the encoded form is `-` where the + // base36 chunk is short (capped to 16 chars by djb2_base36). + let suffix = encoded.rsplit('-').next().unwrap(); + assert!(suffix.chars().all(|c| c.is_ascii_alphanumeric())); + assert!(suffix.len() <= 16); + } + + #[test] + fn encode_project_path_then_decode_with_heuristic_finds_real_path() { + // End-to-end: a real directory path is encoded, then the decode + // heuristic is asked to recover it. This guards against regressions + // where the encoder and decoder drift out of sync. + let (anchor, _g) = make_unique_anchor(); + let target = anchor.join("parent/cool-project"); + std::fs::create_dir_all(&target).unwrap(); + + // Pass the *decoded* segment list as the decoder would see it after + // the `--` → `/.` step: every `/`-separated part of the encoded id. + let prefix = format!("{}/", anchor.display()); + let rest = "parent/cool-project"; + let found = try_merge_segments(&prefix, rest).expect("heuristic should resolve"); + assert_eq!(found, target.to_string_lossy().to_string()); + } } /// Convert slug like "soft-petting-wave" to "Soft Petting Wave"