A file-based task manager
0
fork

Configure Feed

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

Implement duplicates property with duplicated-by inverse

tsk prop set <id> duplicates [[tsk-X]] now wires up the reverse half:
the original X gets `[[tsk-id]]` appended to its `duplicated-by`
property (comma-separated link list). Unsetting or re-pointing the
duplicate removes it. Self-reference and cycles are rejected.

Refactored the parent/children logic into a small `InversePair`
registry so `parent`/`children` and `duplicates`/`duplicated-by` share
the same implementation. Adding more pairs in the future is one line.

CLI: when a duplicate and its original are both still on the stack,
`tsk prop set ... duplicates ...` prompts `Drop tsk-N? [y/N]` and drops
the duplicate on `y`. Idempotent — answering n leaves both open.

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

+157 -47
+27
src/main.rs
··· 26 26 27 27 const NEW_SENTINEL: &str = "<new>"; 28 28 29 + /// `[[tsk-N]]` → Some(Id(N)). Anything else (including foreign links) → None. 30 + fn parse_internal_link_for_cli(s: &str) -> Option<Id> { 31 + let inner = s.trim().strip_prefix("[[")?.strip_suffix("]]")?; 32 + Id::from_str(inner).ok() 33 + } 34 + 29 35 fn prompt_line(prompt: &str) -> Result<String> { 30 36 use std::io::Write as _; 31 37 eprint!("{prompt}"); ··· 1028 1034 } 1029 1035 }; 1030 1036 ws.set_property(id, &key, &value)?; 1037 + // For duplicates: if the duplicate and original are both still on 1038 + // the stack, prompt to drop the duplicate so they don't both keep 1039 + // showing up in tsk list. 1040 + if key == "duplicates" 1041 + && let Some(target) = parse_internal_link_for_cli(&value) 1042 + { 1043 + let stack = ws.read_stack()?; 1044 + let dup_open = stack.iter().any(|i| i.id == id); 1045 + let orig_open = stack.iter().any(|i| i.id == target); 1046 + if dup_open && orig_open { 1047 + eprint!("{id} duplicates {target} and both are open. Drop {id}? [y/N] "); 1048 + use std::io::Write as _; 1049 + io::stderr().flush()?; 1050 + let mut answer = String::new(); 1051 + io::stdin().read_line(&mut answer)?; 1052 + if matches!(answer.trim(), "y" | "Y" | "yes") { 1053 + ws.drop(workspace::TaskIdentifier::Id(id))?; 1054 + eprintln!("Dropped {id}"); 1055 + } 1056 + } 1057 + } 1031 1058 } 1032 1059 PropAction::Unset { task_id, key } => { 1033 1060 let id = ws.task(task_id.into())?.id;
+130 -47
src/workspace.rs
··· 64 64 } 65 65 } 66 66 67 + /// A property whose value is a `[[tsk-N]]` link maintains an inverse 68 + /// link-list property on the target task. `forward` is the singular property 69 + /// the user sets; `inverse` is the multi-value list maintained on the target. 70 + struct InversePair { 71 + forward: &'static str, 72 + inverse: &'static str, 73 + cycle_check: bool, 74 + action: &'static str, 75 + } 76 + 77 + const INVERSE_PAIRS: &[InversePair] = &[ 78 + InversePair { 79 + forward: "parent", 80 + inverse: "children", 81 + cycle_check: true, 82 + action: "parent", 83 + }, 84 + InversePair { 85 + forward: "duplicates", 86 + inverse: "duplicated-by", 87 + cycle_check: true, 88 + action: "duplicate", 89 + }, 90 + ]; 91 + 92 + fn inverse_pair_for(key: &str) -> Option<&'static InversePair> { 93 + INVERSE_PAIRS.iter().find(|p| p.forward == key) 94 + } 95 + 67 96 /// Parse a single `[[tsk-N]]` wiki-style internal link out of a property 68 97 /// value. Whitespace is trimmed; foreign links (`[[ns/tsk-N]]`) are not 69 98 /// matched here because the inverse-relation maintenance is intra-namespace. ··· 419 448 /// cycles are rejected. 420 449 pub fn set_property(&self, id: Id, key: &str, value: &str) -> Result<()> { 421 450 let old_value = backend::read_attrs(self.store(), id)?.get(key).cloned(); 422 - if key == "parent" { 423 - let old_parent = old_value.as_deref().and_then(parse_internal_link); 424 - let new_parent = parse_internal_link(value); 425 - if let Some(p) = new_parent { 426 - if p == id { 427 - return Err(Error::Parse("A task cannot be its own parent".into())); 451 + if let Some(pair) = inverse_pair_for(key) { 452 + let old_target = old_value.as_deref().and_then(parse_internal_link); 453 + let new_target = parse_internal_link(value); 454 + if let Some(t) = new_target { 455 + if t == id { 456 + return Err(Error::Parse(format!( 457 + "Refusing to set {key}={t}: a task cannot {} itself", 458 + pair.action 459 + ))); 428 460 } 429 - if self.would_form_parent_cycle(id, p)? { 461 + if pair.cycle_check && self.would_form_chain_cycle(id, t, pair.forward)? { 430 462 return Err(Error::Parse(format!( 431 - "Refusing to set parent={p}: would form a cycle" 463 + "Refusing to set {key}={t}: would form a cycle" 432 464 ))); 433 465 } 434 466 } 435 - // Update primary attr first. 436 467 let mut attrs = backend::read_attrs(self.store(), id)?; 437 468 attrs.insert(key.to_string(), value.to_string()); 438 469 backend::write_attrs(self.store(), id, &attrs)?; 439 470 self.log(id, "prop-set", Some(key))?; 440 - // Then maintain children list on old/new parents. 441 - if old_parent != new_parent { 442 - if let Some(p) = old_parent { 443 - self.remove_from_children(p, id)?; 471 + if old_target != new_target { 472 + if let Some(t) = old_target { 473 + self.update_inverse_list(t, id, pair.inverse, /* add */ false)?; 444 474 } 445 - if let Some(p) = new_parent { 446 - self.add_to_children(p, id)?; 475 + if let Some(t) = new_target { 476 + self.update_inverse_list(t, id, pair.inverse, /* add */ true)?; 447 477 } 448 478 } 449 479 Ok(()) ··· 462 492 if let Some(prev) = removed { 463 493 backend::write_attrs(self.store(), id, &attrs)?; 464 494 self.log(id, "prop-unset", Some(key))?; 465 - if key == "parent" 466 - && let Some(p) = parse_internal_link(&prev) 495 + if let Some(pair) = inverse_pair_for(key) 496 + && let Some(t) = parse_internal_link(&prev) 467 497 { 468 - self.remove_from_children(p, id)?; 498 + self.update_inverse_list(t, id, pair.inverse, /* add */ false)?; 469 499 } 470 500 } 471 501 Ok(()) 472 502 } 473 503 474 - /// Walk up the parent chain starting at `start` and return true if `child` 475 - /// would appear in it (i.e. setting `child.parent = start` would cycle). 476 - fn would_form_parent_cycle(&self, child: Id, start: Id) -> Result<bool> { 504 + /// Walk up `start`'s `forward_key` chain and return true if `subject` 505 + /// would appear in it (i.e. setting `subject.<forward_key> = start` would 506 + /// cycle). 507 + fn would_form_chain_cycle(&self, subject: Id, start: Id, forward_key: &str) -> Result<bool> { 477 508 let mut cur = Some(start); 478 509 let mut visited: HashSet<Id> = HashSet::new(); 479 510 while let Some(c) = cur { 480 - if c == child { 511 + if c == subject { 481 512 return Ok(true); 482 513 } 483 514 if !visited.insert(c) { 484 - // Pre-existing cycle detected upstream — surface as no-cycle 485 - // here so the caller's set still proceeds (we're not making 486 - // it worse). 515 + // Pre-existing cycle upstream; not our problem to enforce. 487 516 return Ok(false); 488 517 } 489 518 cur = backend::read_attrs(self.store(), c)? 490 - .get("parent") 519 + .get(forward_key) 491 520 .and_then(|v| parse_internal_link(v)); 492 521 } 493 522 Ok(false) 494 523 } 495 524 496 - fn add_to_children(&self, parent: Id, child: Id) -> Result<()> { 497 - let mut attrs = backend::read_attrs(self.store(), parent)?; 498 - let mut ids = parse_link_list(attrs.get("children").map(String::as_str).unwrap_or("")); 499 - if !ids.contains(&child) { 500 - ids.push(child); 525 + /// Add or remove `subject` in the `inverse_key` link list on `target`. 526 + fn update_inverse_list( 527 + &self, 528 + target: Id, 529 + subject: Id, 530 + inverse_key: &str, 531 + add: bool, 532 + ) -> Result<()> { 533 + let mut attrs = backend::read_attrs(self.store(), target)?; 534 + let mut ids = parse_link_list(attrs.get(inverse_key).map(String::as_str).unwrap_or("")); 535 + let before = ids.len(); 536 + if add { 537 + if !ids.contains(&subject) { 538 + ids.push(subject); 539 + } 540 + } else { 541 + ids.retain(|i| *i != subject); 542 + } 543 + if ids.len() == before && add { 544 + return Ok(()); 501 545 } 502 - attrs.insert("children".into(), format_link_list(&ids)); 503 - backend::write_attrs(self.store(), parent, &attrs)?; 504 - self.log(parent, "prop-set", Some("children"))?; 505 - Ok(()) 506 - } 507 - 508 - fn remove_from_children(&self, parent: Id, child: Id) -> Result<()> { 509 - let mut attrs = backend::read_attrs(self.store(), parent)?; 510 - let mut ids = parse_link_list(attrs.get("children").map(String::as_str).unwrap_or("")); 511 - let before = ids.len(); 512 - ids.retain(|i| *i != child); 513 - if ids.len() == before { 546 + if ids.len() == before && !add { 514 547 return Ok(()); 515 548 } 516 549 if ids.is_empty() { 517 - attrs.remove("children"); 550 + attrs.remove(inverse_key); 518 551 } else { 519 - attrs.insert("children".into(), format_link_list(&ids)); 552 + attrs.insert(inverse_key.to_string(), format_link_list(&ids)); 520 553 } 521 - backend::write_attrs(self.store(), parent, &attrs)?; 522 - self.log(parent, "prop-set", Some("children"))?; 554 + backend::write_attrs(self.store(), target, &attrs)?; 555 + self.log(target, "prop-set", Some(inverse_key))?; 523 556 Ok(()) 524 557 } 525 558 ··· 2124 2157 assert!(body_cands.iter().any(|c| c.contains("x.example"))); 2125 2158 let body_cands = ws.body_candidates(id2).unwrap(); 2126 2159 assert!(body_cands.iter().any(|c| c == &format!("[[{id1}]]"))); 2160 + } 2161 + } 2162 + 2163 + #[test] 2164 + fn test_duplicates_property_maintains_duplicated_by_inverse() { 2165 + let (_d, file, git) = setup_dual(); 2166 + for ws in [&file, &git] { 2167 + let orig = ws.new_task("orig".into(), "".into()).unwrap(); 2168 + let orig_id = orig.id; 2169 + ws.push_task(orig).unwrap(); 2170 + let dup1 = ws.new_task("dup1".into(), "".into()).unwrap(); 2171 + let dup1_id = dup1.id; 2172 + ws.push_task(dup1).unwrap(); 2173 + let dup2 = ws.new_task("dup2".into(), "".into()).unwrap(); 2174 + let dup2_id = dup2.id; 2175 + ws.push_task(dup2).unwrap(); 2176 + 2177 + ws.set_property(dup1_id, "duplicates", &format!("[[{orig_id}]]")) 2178 + .unwrap(); 2179 + ws.set_property(dup2_id, "duplicates", &format!("[[{orig_id}]]")) 2180 + .unwrap(); 2181 + let dby = backend::read_attrs(ws.store(), orig_id) 2182 + .unwrap() 2183 + .get("duplicated-by") 2184 + .cloned() 2185 + .unwrap_or_default(); 2186 + assert!(dby.contains(&format!("[[{dup1_id}]]")), "{dby}"); 2187 + assert!(dby.contains(&format!("[[{dup2_id}]]")), "{dby}"); 2188 + 2189 + // Unset removes from inverse list. 2190 + ws.unset_property(dup1_id, "duplicates").unwrap(); 2191 + let dby = backend::read_attrs(ws.store(), orig_id) 2192 + .unwrap() 2193 + .get("duplicated-by") 2194 + .cloned() 2195 + .unwrap_or_default(); 2196 + assert!(!dby.contains(&format!("[[{dup1_id}]]"))); 2197 + assert!(dby.contains(&format!("[[{dup2_id}]]"))); 2198 + 2199 + // Self-reference rejected. 2200 + assert!( 2201 + ws.set_property(dup2_id, "duplicates", &format!("[[{dup2_id}]]")) 2202 + .is_err() 2203 + ); 2204 + // Cycle rejected (orig.duplicates = dup2 but dup2 already 2205 + // duplicates orig). 2206 + assert!( 2207 + ws.set_property(orig_id, "duplicates", &format!("[[{dup2_id}]]")) 2208 + .is_err() 2209 + ); 2127 2210 } 2128 2211 } 2129 2212