A file-based task manager
0
fork

Configure Feed

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

Maintain children inverse when parent property changes

`tsk prop set <id> parent [[tsk-X]]` now wires up the reverse half:
the new parent gets `[[tsk-id]]` appended to its `children` property
(comma-separated link list). Re-parenting moves the entry; unsetting
removes it; deleting the only child clears the property entirely.

Guards:
- self-parent rejected
- cycles rejected (walks up the parent chain from the proposed parent)

Non-link values for `parent` are stored as-is with no inverse
maintenance, since there's nothing to point back at. Foreign-link
parents (`[[ns/tsk-N]]`) intentionally don't trigger the inverse —
the bidirectional invariant only makes sense intra-namespace.

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

+200 -5
+200 -5
src/workspace.rs
··· 64 64 } 65 65 } 66 66 67 + /// Parse a single `[[tsk-N]]` wiki-style internal link out of a property 68 + /// value. Whitespace is trimmed; foreign links (`[[ns/tsk-N]]`) are not 69 + /// matched here because the inverse-relation maintenance is intra-namespace. 70 + fn parse_internal_link(s: &str) -> Option<Id> { 71 + let inner = s.trim().strip_prefix("[[")?.strip_suffix("]]")?; 72 + Id::from_str(inner).ok() 73 + } 74 + 75 + /// Parse a comma-separated list of `[[tsk-N]]` links, ignoring entries that 76 + /// don't parse cleanly. 77 + fn parse_link_list(s: &str) -> Vec<Id> { 78 + s.split(',').filter_map(parse_internal_link).collect() 79 + } 80 + 81 + fn format_link_list(ids: &[Id]) -> String { 82 + ids.iter() 83 + .map(|i| format!("[[{i}]]")) 84 + .collect::<Vec<_>>() 85 + .join(",") 86 + } 87 + 67 88 enum PullAction { 68 89 /// Local already matches remote — nothing to do. 69 90 Skip, ··· 390 411 391 412 /// Set a single property (a.k.a attribute) on a task. Empty value is 392 413 /// allowed for unary properties. 414 + /// 415 + /// Side effect: when `key == "parent"` and the value parses as an 416 + /// internal link `[[tsk-N]]`, the inverse entry is added to the parent's 417 + /// `children` property (comma-separated link list). A previous parent 418 + /// (if any) has the child removed from its `children`. Self-parents and 419 + /// cycles are rejected. 393 420 pub fn set_property(&self, id: Id, key: &str, value: &str) -> Result<()> { 394 - let mut attrs = backend::read_attrs(self.store(), id)?; 395 - attrs.insert(key.to_string(), value.to_string()); 396 - backend::write_attrs(self.store(), id, &attrs)?; 397 - self.log(id, "prop-set", Some(key)) 421 + 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())); 428 + } 429 + if self.would_form_parent_cycle(id, p)? { 430 + return Err(Error::Parse(format!( 431 + "Refusing to set parent={p}: would form a cycle" 432 + ))); 433 + } 434 + } 435 + // Update primary attr first. 436 + let mut attrs = backend::read_attrs(self.store(), id)?; 437 + attrs.insert(key.to_string(), value.to_string()); 438 + backend::write_attrs(self.store(), id, &attrs)?; 439 + 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)?; 444 + } 445 + if let Some(p) = new_parent { 446 + self.add_to_children(p, id)?; 447 + } 448 + } 449 + Ok(()) 450 + } else { 451 + let mut attrs = backend::read_attrs(self.store(), id)?; 452 + attrs.insert(key.to_string(), value.to_string()); 453 + backend::write_attrs(self.store(), id, &attrs)?; 454 + self.log(id, "prop-set", Some(key)) 455 + } 398 456 } 399 457 400 458 /// Remove a property from a task. No-op if not present. 401 459 pub fn unset_property(&self, id: Id, key: &str) -> Result<()> { 402 460 let mut attrs = backend::read_attrs(self.store(), id)?; 403 - if attrs.remove(key).is_some() { 461 + let removed = attrs.remove(key); 462 + if let Some(prev) = removed { 404 463 backend::write_attrs(self.store(), id, &attrs)?; 405 464 self.log(id, "prop-unset", Some(key))?; 465 + if key == "parent" 466 + && let Some(p) = parse_internal_link(&prev) 467 + { 468 + self.remove_from_children(p, id)?; 469 + } 406 470 } 471 + Ok(()) 472 + } 473 + 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> { 477 + let mut cur = Some(start); 478 + let mut visited: HashSet<Id> = HashSet::new(); 479 + while let Some(c) = cur { 480 + if c == child { 481 + return Ok(true); 482 + } 483 + 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). 487 + return Ok(false); 488 + } 489 + cur = backend::read_attrs(self.store(), c)? 490 + .get("parent") 491 + .and_then(|v| parse_internal_link(v)); 492 + } 493 + Ok(false) 494 + } 495 + 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); 501 + } 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 { 514 + return Ok(()); 515 + } 516 + if ids.is_empty() { 517 + attrs.remove("children"); 518 + } else { 519 + attrs.insert("children".into(), format_link_list(&ids)); 520 + } 521 + backend::write_attrs(self.store(), parent, &attrs)?; 522 + self.log(parent, "prop-set", Some("children"))?; 407 523 Ok(()) 408 524 } 409 525 ··· 1930 2046 zip.file_names().map(|s| s.to_string()).collect(); 1931 2047 assert!(names.contains(&format!("log/{}", id.0))); 1932 2048 std::fs::remove_file(&dest).unwrap(); 2049 + } 2050 + } 2051 + 2052 + #[test] 2053 + fn test_parent_property_maintains_children_inverse() { 2054 + let (_d, file, git) = setup_dual(); 2055 + for ws in [&file, &git] { 2056 + let p = ws.new_task("parent".into(), "".into()).unwrap(); 2057 + let parent_id = p.id; 2058 + ws.push_task(p).unwrap(); 2059 + let c1 = ws.new_task("child1".into(), "".into()).unwrap(); 2060 + let c1_id = c1.id; 2061 + ws.push_task(c1).unwrap(); 2062 + let c2 = ws.new_task("child2".into(), "".into()).unwrap(); 2063 + let c2_id = c2.id; 2064 + ws.push_task(c2).unwrap(); 2065 + 2066 + // Set parents on both children → parent gets a children list. 2067 + ws.set_property(c1_id, "parent", &format!("[[{parent_id}]]")) 2068 + .unwrap(); 2069 + ws.set_property(c2_id, "parent", &format!("[[{parent_id}]]")) 2070 + .unwrap(); 2071 + let parent_props = backend::read_attrs(ws.store(), parent_id).unwrap(); 2072 + let children = parent_props.get("children").cloned().unwrap_or_default(); 2073 + assert!( 2074 + children.contains(&format!("[[{c1_id}]]")), 2075 + "expected c1 in {children}" 2076 + ); 2077 + assert!( 2078 + children.contains(&format!("[[{c2_id}]]")), 2079 + "expected c2 in {children}" 2080 + ); 2081 + 2082 + // Unset the parent on c1 → it disappears from parent's children. 2083 + ws.unset_property(c1_id, "parent").unwrap(); 2084 + let parent_props = backend::read_attrs(ws.store(), parent_id).unwrap(); 2085 + let children = parent_props.get("children").cloned().unwrap_or_default(); 2086 + assert!( 2087 + !children.contains(&format!("[[{c1_id}]]")), 2088 + "c1 should be gone: {children}" 2089 + ); 2090 + assert!(children.contains(&format!("[[{c2_id}]]"))); 2091 + 2092 + // Re-parent c2 to a different parent → c2 leaves old parent's 2093 + // children list. 2094 + let p2 = ws.new_task("parent2".into(), "".into()).unwrap(); 2095 + let p2_id = p2.id; 2096 + ws.push_task(p2).unwrap(); 2097 + ws.set_property(c2_id, "parent", &format!("[[{p2_id}]]")) 2098 + .unwrap(); 2099 + let old = backend::read_attrs(ws.store(), parent_id).unwrap(); 2100 + assert!(old.get("children").is_none(), "old parent should be empty"); 2101 + let new = backend::read_attrs(ws.store(), p2_id).unwrap(); 2102 + assert!( 2103 + new.get("children") 2104 + .unwrap() 2105 + .contains(&format!("[[{c2_id}]]")) 2106 + ); 2107 + 2108 + // Self-parent is rejected. 2109 + assert!( 2110 + ws.set_property(c2_id, "parent", &format!("[[{c2_id}]]")) 2111 + .is_err() 2112 + ); 2113 + // Cycle (p2.parent = c2 — c2's parent is already p2) is rejected. 2114 + assert!( 2115 + ws.set_property(p2_id, "parent", &format!("[[{c2_id}]]")) 2116 + .is_err() 2117 + ); 2118 + 2119 + // Non-link values store fine without inverse maintenance. 2120 + ws.set_property(c2_id, "tag", "important").unwrap(); 2121 + assert_eq!( 2122 + backend::read_attrs(ws.store(), c2_id) 2123 + .unwrap() 2124 + .get("tag") 2125 + .map(String::as_str), 2126 + Some("important") 2127 + ); 1933 2128 } 1934 2129 } 1935 2130