Skip to content

Commit c8a9ffc

Browse files
koki-developclaude
andcommitted
feat: allow task titles to contain slashes
Titles are stored as the filename stem, so `/` was previously replaced with `-`. Encode `/` as U+2215 (DIVISION SLASH) on disk and decode it back for display instead, routing every title⟷filename conversion through encode_title_to_stem / decode_stem_to_title so the UI and MCP always see real slashes while the on-disk path stays filesystem-safe. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9c3eef2 commit c8a9ffc

2 files changed

Lines changed: 109 additions & 52 deletions

File tree

src-tauri/AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ Covered modules:
7979
- `state.rs``AppState` API + cross-thread sharing via `Arc` + per-window isolation, `remove_window`, `next_window_label` monotonicity / thread safety, `set_workspace` scoping invariant, MCP runtime transitions (`Stopped` / `Failed` / `is_mcp_running` predicate, `with_mcp_handle` non-`Running` no-op)
8080
- `security.rs``canonical_workspace` / `ensure_in_workspace` / `check_in_workspace`, including symlink and `..` escape rejection
8181
- `frontmatter.rs``parse` / `update` / `serialize` and the private `format_yaml_float`
82-
- `task.rs``sanitize_title`, `read_task_preview`, and `compute_reconciled_orders` (including the multi-window invariant that internal moves with `status` and `order` both changed don't trigger external-edit repair)
82+
- `task.rs``encode_title_to_stem` / `decode_stem_to_title` (the `/`⟷U+2215 title-filename swap, round-trip + reserved-sentinel invariants), `read_task_preview`, and `compute_reconciled_orders` (including the multi-window invariant that internal moves with `status` and `order` both changed don't trigger external-edit repair)
8383
- `workspace.rs``parse_workspace_history` / `history_to_json` / `prepend_unique_capped` (workspace-history pure helpers), `update_workspaces_map` (persisted-store mutation), and `filter_existing_directories` (the `is_dir()` survival filter backing `list_workspace_history`)
8484
- `mcp.rs` — token minting / validation / `McpSettings` round-trip / store-key wire-shape pinning / `resolve_settings` (cold-start + corrupt-token rotation, preserving `enabled`), `slug_for_workspace`, `build_sample_config` (single / multi / duplicate-slug disambiguation / stable FNV-1a), `McpRuntime` / `McpStatus` / `McpStartError` (Display + tagged-enum wire format), `is_token_only_change` branches, `auth_layer` (matching Bearer / mismatch / missing / lowercase `bearer` NG / extra-whitespace NG / `WWW-Authenticate` header presence), `workspace_layer` (valid dir / missing header / empty value / non-existent path / file-not-dir), tool-router registration end-to-end (`CorkMcpServer::tool_router()` doesn't panic on the MCP `outputSchema` root-must-be-object validation; `ListTasksOutput` schema root is `object`), `list_tasks` pagination (`resolve_limit` default/clamp, `paginate` page boundaries / offset-past-end / `has_more`)
8585

src-tauri/src/task.rs

Lines changed: 108 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -282,11 +282,7 @@ pub(crate) fn read_all_tasks(dir: &Path) -> Vec<Task> {
282282
.par_iter()
283283
.filter_map(|file_path| {
284284
let content = read_task_preview(file_path)?;
285-
let title = file_path
286-
.file_stem()
287-
.and_then(|s| s.to_str())
288-
.unwrap_or("")
289-
.to_string();
285+
let title = decode_stem_to_title(file_stem_str(file_path));
290286
let (fm, body) = frontmatter::parse::<TaskFrontmatter>(&content);
291287
let f = fm?;
292288
let status = f.status.filter(|s| !s.is_empty())?;
@@ -316,11 +312,7 @@ pub fn get_task(
316312
let path = security::ensure_in_workspace(&dir, Path::new(&path))?;
317313

318314
let content = fs::read_to_string(&path)?;
319-
let title = path
320-
.file_stem()
321-
.and_then(|s| s.to_str())
322-
.unwrap_or("")
323-
.to_string();
315+
let title = decode_stem_to_title(file_stem_str(&path));
324316
let (fm, body) = frontmatter::parse::<TaskFrontmatter>(&content);
325317
let f = fm.ok_or(CommandError::MissingFrontmatter)?;
326318
let status = f.status.unwrap_or_default();
@@ -382,10 +374,10 @@ pub fn write_task_file(
382374
tags: Option<Vec<String>>,
383375
date: Option<String>,
384376
) -> CmdResult<Task> {
385-
let title = sanitize_title(title)?;
377+
let stem = encode_title_to_stem(title)?;
386378
let date = normalize_date_arg(date)?;
387379

388-
let file_path = dir.join(format!("{}.md", title));
380+
let file_path = dir.join(format!("{}.md", stem));
389381
if file_path.exists() {
390382
return Err(CommandError::DuplicateTask);
391383
}
@@ -409,7 +401,7 @@ pub fn write_task_file(
409401
let id = file_path.to_string_lossy().to_string();
410402
Ok(Task {
411403
id,
412-
title,
404+
title: decode_stem_to_title(&stem),
413405
status: status.to_string(),
414406
body: body.to_string(),
415407
order,
@@ -479,19 +471,21 @@ pub fn update_task(
479471
.map(|f| f.tags.clone())
480472
.unwrap_or_default();
481473
let current_date = fm.as_ref().and_then(|f| f.date.clone());
482-
let current_title = path_canonical
483-
.file_stem()
484-
.and_then(|s| s.to_str())
485-
.unwrap_or("")
486-
.to_string();
487-
488-
let (new_title, title_changed) = match title {
474+
let current_stem = file_stem_str(&path_canonical).to_string();
475+
476+
// Change detection and the new filename both work in *stem space* (the
477+
// on-disk form, with `/` already encoded to the sentinel) so the comparison
478+
// is apples-to-apples; the user-facing title is decoded only at the return.
479+
// Note the asymmetry with the read paths above (`read_all_tasks` / `get_task`),
480+
// which wrap `file_stem_str` in `decode_stem_to_title` — here the raw stem is
481+
// intentionally kept undecoded so it compares like-for-like against `encoded`.
482+
let (new_stem, title_changed) = match title {
489483
Some(t) => {
490-
let sanitized = sanitize_title(&t)?;
491-
let changed = sanitized != current_title;
492-
(sanitized, changed)
484+
let encoded = encode_title_to_stem(&t)?;
485+
let changed = encoded != current_stem;
486+
(encoded, changed)
493487
}
494-
None => (current_title, false),
488+
None => (current_stem, false),
495489
};
496490

497491
// Lift the three semantic states of `tags` into an enum so the
@@ -560,7 +554,7 @@ pub fn update_task(
560554

561555
let old_id = path_canonical.to_string_lossy().to_string();
562556
let target_path = if title_changed {
563-
let new_path = dir_canonical.join(format!("{}.md", new_title));
557+
let new_path = dir_canonical.join(format!("{}.md", new_stem));
564558
rename_and_write_task(&path_canonical, &new_path, &new_content)?;
565559
// Title change = file rename = id change. Drop the old key so the
566560
// snapshot doesn't carry a phantom entry for a path no longer on
@@ -579,7 +573,7 @@ pub fn update_task(
579573

580574
Ok(Task {
581575
id,
582-
title: new_title,
576+
title: decode_stem_to_title(&new_stem),
583577
status: new_status,
584578
body: new_body,
585579
order: current_order,
@@ -845,19 +839,51 @@ fn rename_and_write_task(src: &Path, dst: &Path, content: &str) -> CmdResult<()>
845839
Ok(())
846840
}
847841

848-
fn sanitize_title(title: &str) -> CmdResult<String> {
849-
let sanitized: String = title
842+
/// On-disk stand-in for `/` in a task title. A POSIX filename can never contain
843+
/// `/` — it is the path separator — yet titles routinely want one ("Frontend/Backend",
844+
/// "2026/Q2"). Cork keeps the filename as the single source of truth for the title
845+
/// (there is no separate `title:` frontmatter), so we swap `/` for U+2215 DIVISION
846+
/// SLASH on disk and swap it back for display. U+2215 is chosen because it is a
847+
/// near-perfect visual match for `/`, is a legal filename character on every major
848+
/// OS, and is effectively never typed by hand — safe to reserve as our sentinel.
849+
/// `encode_title_to_stem` / `decode_stem_to_title` are the *only* place this swap
850+
/// happens; every filename⟷title conversion must run through them.
851+
const TITLE_SLASH_SENTINEL: char = '\u{2215}';
852+
853+
/// Convert a user-facing title into the file stem stored on disk: replace `/` with
854+
/// the sentinel, drop null bytes, and trim surrounding whitespace. Rejects a title
855+
/// that is empty after normalization. The sentinel is reserved — a title that
856+
/// already contains a literal U+2215 is left as-is and therefore round-trips back
857+
/// to `/` via `decode_stem_to_title`, never persisting a distinct U+2215.
858+
fn encode_title_to_stem(title: &str) -> CmdResult<String> {
859+
let encoded: String = title
850860
.chars()
851-
.map(|c| if c == '/' { '-' } else { c })
852861
.filter(|&c| c != '\0')
862+
.map(|c| if c == '/' { TITLE_SLASH_SENTINEL } else { c })
853863
.collect();
854-
let trimmed = sanitized.trim().to_string();
864+
let trimmed = encoded.trim().to_string();
855865
if trimmed.is_empty() {
856866
return Err(CommandError::EmptyTitle);
857867
}
858868
Ok(trimmed)
859869
}
860870

871+
/// Inverse of `encode_title_to_stem` for the slash swap: restore `/` from the
872+
/// sentinel in an on-disk file stem. The other normalization steps
873+
/// (null-stripping, trimming) are intentionally not reversed — they are lossy by
874+
/// design and the stem is already clean.
875+
fn decode_stem_to_title(stem: &str) -> String {
876+
stem.replace(TITLE_SLASH_SENTINEL, "/")
877+
}
878+
879+
/// The file stem as a `&str`, falling back to `""` for a path with no stem or a
880+
/// non-UTF-8 stem. This is the on-disk (encoded) form; run it through
881+
/// `decode_stem_to_title` to get the user-facing title. Centralizes the
882+
/// `file_stem` → `to_str` → fallback chain shared by the read paths.
883+
fn file_stem_str(path: &Path) -> &str {
884+
path.file_stem().and_then(|s| s.to_str()).unwrap_or("")
885+
}
886+
861887
/// Reads only frontmatter + up to 100 bytes of body from a file.
862888
/// Returns None if the file does not start with "---" (no frontmatter).
863889
fn read_task_preview(file_path: &Path) -> Option<String> {
@@ -903,59 +929,90 @@ mod tests {
903929
use std::fs;
904930
use tempfile::TempDir;
905931

906-
// --- sanitize_title ------------------------------------------------------
932+
// --- encode_title_to_stem / decode_stem_to_title -------------------------
907933

908934
#[test]
909-
fn sanitize_title_replaces_path_separators() {
910-
assert_eq!(sanitize_title("foo/bar").unwrap(), "foo-bar");
911-
assert_eq!(sanitize_title("a/b/c").unwrap(), "a-b-c");
935+
fn encode_title_to_stem_swaps_slashes_for_sentinel() {
936+
assert_eq!(encode_title_to_stem("foo/bar").unwrap(), "foo\u{2215}bar");
937+
assert_eq!(
938+
encode_title_to_stem("a/b/c").unwrap(),
939+
"a\u{2215}b\u{2215}c"
940+
);
912941
}
913942

914943
#[test]
915-
fn sanitize_title_filters_null_bytes() {
916-
assert_eq!(sanitize_title("hello\0world").unwrap(), "helloworld");
944+
fn encode_title_to_stem_filters_null_bytes() {
945+
assert_eq!(encode_title_to_stem("hello\0world").unwrap(), "helloworld");
917946
}
918947

919948
#[test]
920-
fn sanitize_title_trims_surrounding_whitespace() {
921-
assert_eq!(sanitize_title(" hi ").unwrap(), "hi");
922-
assert_eq!(sanitize_title("\t\nhi\n\t").unwrap(), "hi");
949+
fn encode_title_to_stem_trims_surrounding_whitespace() {
950+
assert_eq!(encode_title_to_stem(" hi ").unwrap(), "hi");
951+
assert_eq!(encode_title_to_stem("\t\nhi\n\t").unwrap(), "hi");
923952
}
924953

925954
#[test]
926-
fn sanitize_title_preserves_unicode() {
927-
assert_eq!(sanitize_title("日本語タイトル").unwrap(), "日本語タイトル");
928-
assert_eq!(sanitize_title("emoji-🎉").unwrap(), "emoji-🎉");
955+
fn encode_title_to_stem_preserves_unicode() {
956+
assert_eq!(
957+
encode_title_to_stem("日本語タイトル").unwrap(),
958+
"日本語タイトル"
959+
);
960+
assert_eq!(encode_title_to_stem("emoji-🎉").unwrap(), "emoji-🎉");
929961
}
930962

931963
#[test]
932-
fn sanitize_title_rejects_empty_string() {
964+
fn encode_title_to_stem_rejects_empty_string() {
933965
assert!(matches!(
934-
sanitize_title("").unwrap_err(),
966+
encode_title_to_stem("").unwrap_err(),
935967
CommandError::EmptyTitle
936968
));
937969
}
938970

939971
#[test]
940-
fn sanitize_title_rejects_whitespace_only() {
972+
fn encode_title_to_stem_rejects_whitespace_only() {
941973
assert!(matches!(
942-
sanitize_title(" ").unwrap_err(),
974+
encode_title_to_stem(" ").unwrap_err(),
943975
CommandError::EmptyTitle
944976
));
945977
}
946978

947979
#[test]
948-
fn sanitize_title_rejects_only_null_bytes() {
980+
fn encode_title_to_stem_rejects_only_null_bytes() {
949981
assert!(matches!(
950-
sanitize_title("\0\0\0").unwrap_err(),
982+
encode_title_to_stem("\0\0\0").unwrap_err(),
951983
CommandError::EmptyTitle
952984
));
953985
}
954986

955987
#[test]
956-
fn sanitize_title_composes_replacements_with_trim() {
957-
// Slash replacement happens before trim.
958-
assert_eq!(sanitize_title(" foo/bar ").unwrap(), "foo-bar");
988+
fn encode_title_to_stem_composes_replacements_with_trim() {
989+
// Slash replacement happens before trim; the sentinel is not whitespace
990+
// so the surrounding spaces still get trimmed away.
991+
assert_eq!(encode_title_to_stem(" foo/bar ").unwrap(), "foo\u{2215}bar");
992+
}
993+
994+
#[test]
995+
fn decode_stem_to_title_restores_slashes() {
996+
assert_eq!(decode_stem_to_title("foo\u{2215}bar"), "foo/bar");
997+
assert_eq!(decode_stem_to_title("plain"), "plain");
998+
}
999+
1000+
#[test]
1001+
fn title_round_trips_through_stem() {
1002+
for title in ["foo/bar", "a/b/c", "日本語/タイトル", "2026/Q2", "plain"] {
1003+
let stem = encode_title_to_stem(title).unwrap();
1004+
assert!(!stem.contains('/'), "stem must be filename-safe: {stem:?}");
1005+
assert_eq!(decode_stem_to_title(&stem), title);
1006+
}
1007+
}
1008+
1009+
#[test]
1010+
fn literal_sentinel_in_title_is_reserved_for_slash() {
1011+
// A title that already contains a literal U+2215 is treated as if it
1012+
// were `/`: the encode leaves it untouched and the decode maps it to
1013+
// `/`, so a distinct U+2215 is never persisted (see TITLE_SLASH_SENTINEL).
1014+
let stem = encode_title_to_stem("a\u{2215}b").unwrap();
1015+
assert_eq!(decode_stem_to_title(&stem), "a/b");
9591016
}
9601017

9611018
// --- rename_and_write_task -----------------------------------------------

0 commit comments

Comments
 (0)