A file-based task manager
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Make source/[[ns/tsk-N]] cross-namespace links recoverable

The `tsk accept` flow already sets `source=[[<src-ns>/tsk-N]]` on the
local copy, but those links weren't parseable: the link parser rejected
prefixes containing `/`, so neither tsk show's superscript rendering
nor tsk follow could navigate them.

Add a third link kind `Namespaced { namespace, id }` to the parser and
wire it through:

- task::parse recognises `[[<ident>/tsk-N]]` and registers Namespaced.
- Workspace::resolve_namespaced_link reads the matching task from a
sibling namespace's GitStore in the same repo (file-backed
workspaces error out).
- tsk follow handles the new kind by printing the resolved task's
content; editing across namespaces is refused (ergonomic enough
given the existing tsk switch + edit flow).

Source values like `[[default/tsk-3]]` now show up with a numbered
superscript in `tsk show` and can be opened with `tsk follow -l N` or
`tsk follow -s`. Same path works for `assigned` values.

Tests cover parser output for the new kind and invalid namespace
characters, and resolve_namespaced_link's positive/missing-namespace/
missing-id paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+137 -6
+18
src/main.rs
··· 692 692 ParsedLink::External(url) => url.to_string(), 693 693 ParsedLink::Internal(id) => format!("[[{id}]]"), 694 694 ParsedLink::Foreign { prefix, id } => format!("[[{prefix}-{id}]]"), 695 + ParsedLink::Namespaced { namespace, id } => format!("[[{namespace}/{id}]]"), 695 696 } 696 697 } 697 698 ··· 770 771 exit(1); 771 772 } 772 773 Ok(()) 774 + } 775 + ParsedLink::Namespaced { namespace, id } => { 776 + let workspace = Workspace::from_path(dir.clone())?; 777 + if edit { 778 + eprintln!("Editing tasks in another namespace is not supported."); 779 + exit(1); 780 + } 781 + match workspace.resolve_namespaced_link(namespace, *id)? { 782 + Some(task) => { 783 + println!("{task}"); 784 + Ok(()) 785 + } 786 + None => { 787 + eprintln!("Task {namespace}/{id} not found."); 788 + exit(1); 789 + } 790 + } 773 791 } 774 792 } 775 793 }
+45 -6
src/task.rs
··· 50 50 #[derive(Debug, Eq, PartialEq, Clone)] 51 51 pub(crate) enum ParsedLink { 52 52 Internal(Id), 53 - Foreign { prefix: String, id: u32 }, 53 + Foreign { 54 + prefix: String, 55 + id: u32, 56 + }, 57 + /// `[[<namespace>/tsk-N]]` — a task in a sibling namespace of the same repo. 58 + Namespaced { 59 + namespace: String, 60 + id: Id, 61 + }, 54 62 External(Url), 55 63 } 56 64 ··· 92 100 (']', ']', Some(InternalLink(il, s_pos))) => { 93 101 state.pop(); 94 102 let contents = s.get(s_pos + 1..char_pos - 1)?; 103 + let valid_ident = |s: &str| { 104 + !s.is_empty() 105 + && s.chars() 106 + .all(|c| c.is_alphanumeric() || c == '_' || c == '-') 107 + }; 95 108 if let Ok(id) = Id::from_str(contents) { 96 109 let linktext = format!( 97 110 "{}{}", ··· 100 113 ); 101 114 out.replace_range(il - 1..out.len(), &linktext); 102 115 links.push(ParsedLink::Internal(id)); 116 + } else if let Some((ns, rest)) = contents.split_once('/') 117 + && valid_ident(ns) 118 + && let Ok(id) = Id::from_str(rest) 119 + { 120 + let linktext = 121 + format!("{}{}", contents.cyan(), super_num(links.len() + 1).cyan()); 122 + out.replace_range(il - 1..out.len(), &linktext); 123 + links.push(ParsedLink::Namespaced { 124 + namespace: ns.to_string(), 125 + id, 126 + }); 103 127 } else if let Some((prefix, id_str)) = contents.split_once('-') 104 128 && let Ok(id) = id_str.parse::<u32>() 105 - && prefix.chars().all(|c| c.is_alphanumeric() || c == '_') 129 + && valid_ident(prefix) 106 130 { 107 131 let linktext = 108 132 format!("{}{}", contents.cyan(), super_num(links.len() + 1).cyan()); ··· 515 539 assert_eq!(input, output.content); 516 540 } 517 541 518 - /// A foreign-style link whose prefix contains `/` (or any other non-ident 519 - /// character) is not a valid namespace; don't register a link, keep the 520 - /// bracketed text as-is. 542 + /// `[[<namespace>/tsk-N]]` registers as a Namespaced link — used for 543 + /// cross-namespace references within a single git repo. 521 544 #[test] 522 - fn test_foreign_link_prefix_with_slash() { 545 + fn test_namespaced_link() { 523 546 setup(); 524 547 let input = "see [[ns/tsk-12]]\n"; 548 + let output = parse(input).expect("parse to work"); 549 + assert_eq!( 550 + &[ParsedLink::Namespaced { 551 + namespace: "ns".into(), 552 + id: Id(12) 553 + }], 554 + output.links.as_slice() 555 + ); 556 + } 557 + 558 + /// A bracketed phrase whose namespace segment contains a non-ident 559 + /// character isn't a valid link; keep the text and don't register one. 560 + #[test] 561 + fn test_namespaced_link_invalid_namespace() { 562 + setup(); 563 + let input = "see [[a b/tsk-12]]\n"; 525 564 let output = parse(input).expect("parse to work"); 526 565 assert!(output.links.is_empty(), "{:?}", output.links); 527 566 assert_eq!(input, output.content);
+74
src/workspace.rs
··· 646 646 crate::task::ParsedLink::External(u) => u.to_string(), 647 647 crate::task::ParsedLink::Internal(i) => format!("[[{i}]]"), 648 648 crate::task::ParsedLink::Foreign { prefix, id } => format!("[[{prefix}-{id}]]"), 649 + crate::task::ParsedLink::Namespaced { namespace, id } => { 650 + format!("[[{namespace}/{id}]]") 651 + } 649 652 }) 650 653 .collect()) 651 654 } ··· 892 895 let workspace = Workspace::from_path(remote.path.clone())?; 893 896 let task = workspace.task(TaskIdentifier::Id(Id(id)))?; 894 897 Ok(Some(task)) 898 + } 899 + 900 + /// Resolve a `[[<namespace>/tsk-N]]` link by reading the task in a sibling 901 + /// namespace of the same git repo. Returns `Ok(None)` if the task isn't 902 + /// found, or an error if the workspace isn't git-backed. 903 + pub fn resolve_namespaced_link(&self, namespace: &str, id: Id) -> Result<Option<Task>> { 904 + if !self.is_git_backed() { 905 + return Err(Error::Parse( 906 + "Cross-namespace links only work on git-backed workspaces".into(), 907 + )); 908 + } 909 + let marker = std::fs::read_to_string(self.path.join(backend::GIT_BACKED_MARKER))?; 910 + let store = 911 + backend::GitStore::open_namespace(PathBuf::from(marker.trim()), namespace.to_string())?; 912 + let Some((title, body, _)) = backend::read_task(&store, id)? else { 913 + return Ok(None); 914 + }; 915 + Ok(Some(Task { 916 + id, 917 + title, 918 + body, 919 + attributes: backend::read_attrs(&store, id)?, 920 + })) 895 921 } 896 922 897 923 fn require_git_dir(&self) -> Result<PathBuf> { ··· 2366 2392 crate::task::ParsedLink::External(_) => "ext", 2367 2393 crate::task::ParsedLink::Internal(_) => "int", 2368 2394 crate::task::ParsedLink::Foreign { .. } => "for", 2395 + crate::task::ParsedLink::Namespaced { .. } => "ns", 2369 2396 }) 2370 2397 .collect(); 2371 2398 assert_eq!(kinds, vec!["ext", "int", "ext", "for"]); 2399 + } 2400 + 2401 + #[test] 2402 + fn test_parsed_namespaced_link() { 2403 + let body = "see [[default/tsk-1]] in default ns"; 2404 + let parsed = parse_task(&format!("\n\n{body}")).expect("parse"); 2405 + match parsed.links.as_slice() { 2406 + [crate::task::ParsedLink::Namespaced { namespace, id }] => { 2407 + assert_eq!(namespace, "default"); 2408 + assert_eq!(id.0, 1); 2409 + } 2410 + other => panic!("expected one Namespaced link, got {other:?}"), 2411 + } 2412 + } 2413 + 2414 + #[test] 2415 + fn test_resolve_namespaced_link_reads_sibling_namespace() { 2416 + let dir = tempfile::tempdir().unwrap(); 2417 + let root = dir.path().to_path_buf(); 2418 + run_git_init(&root); 2419 + Workspace::init(root.clone()).unwrap(); 2420 + let ws = Workspace::from_path(root.clone()).unwrap(); 2421 + 2422 + // Create a task in default. 2423 + let t = ws.new_task("the-original".into(), "body".into()).unwrap(); 2424 + let id = t.id; 2425 + ws.push_task(t).unwrap(); 2426 + 2427 + // Switch to alice and look up the link from there. 2428 + ws.switch_namespace("alice").unwrap(); 2429 + let alice = Workspace::from_path(root.clone()).unwrap(); 2430 + let resolved = alice 2431 + .resolve_namespaced_link("default", id) 2432 + .unwrap() 2433 + .expect("should find original"); 2434 + assert_eq!(resolved.title, "the-original"); 2435 + assert_eq!(resolved.body, "body"); 2436 + 2437 + // Missing namespace → None. 2438 + assert!(alice.resolve_namespaced_link("nope", id).unwrap().is_none()); 2439 + // Missing id in real namespace → None. 2440 + assert!( 2441 + alice 2442 + .resolve_namespaced_link("default", Id(9999)) 2443 + .unwrap() 2444 + .is_none() 2445 + ); 2372 2446 } 2373 2447 2374 2448 #[test]