@@ -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).
863889fn 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\0 world" ) . unwrap( ) , "helloworld" ) ;
944+ fn encode_title_to_stem_filters_null_bytes ( ) {
945+ assert_eq ! ( encode_title_to_stem ( "hello\0 world" ) . 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 \n hi\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 \n hi\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