use std::collections::{HashMap, HashSet};
use crate::model::task::{Metadata, Task};
use crate::model::task_id::{Segment, TaskId, Token};
use crate::model::track::{Track, TrackNode};
pub fn validate_merge_request(from: &[String], into: &str) -> Result<(), String> {
crate::io::actors::validate_token(into)?;
if into == "null" {
return Err(
"cannot merge into the null (primary) namespace — pick a tokened target".to_string(),
);
}
if from.is_empty() {
return Err(
"no source tokens given (usage: fr actor merge <from>... --into <token>)".into(),
);
}
let mut seen = HashSet::new();
for tok in from {
crate::io::actors::validate_token(tok)?;
if tok == "null" {
return Err("cannot merge the null (primary) namespace away".to_string());
}
if tok == into {
return Err(format!("token '{}' is both a source and the target", tok));
}
if !seen.insert(tok.clone()) {
return Err(format!("token '{}' listed more than once", tok));
}
}
Ok(())
}
fn seg_is_into(seg: &Segment, into: Option<&Token>) -> bool {
seg.token.as_ref() == into
}
fn last_in_from(id: &TaskId, from: &HashSet<String>) -> bool {
id.segments()
.and_then(|(_, segs)| segs.last())
.and_then(|s| s.token.as_ref())
.map(|t| from.contains(t.as_str()))
.unwrap_or(false)
}
fn parent_new_segments(id: &TaskId, memo: &HashMap<String, TaskId>) -> Vec<Segment> {
let (prefix, segs) = id.segments().expect("structured id");
if segs.len() <= 1 {
return Vec::new();
}
let parent_old = TaskId::from_segments(prefix, segs[..segs.len() - 1].to_vec());
match memo.get(parent_old.as_str()) {
Some(pnew) => pnew.segments().map(|(_, s)| s.to_vec()).unwrap_or_default(),
None => segs[..segs.len() - 1].to_vec(),
}
}
fn context_key(prefix: &str, parent_new: &[Segment]) -> (String, String) {
let path = if parent_new.is_empty() {
String::new()
} else {
TaskId::from_segments(prefix, parent_new.to_vec())
.as_str()
.to_string()
};
(prefix.to_string(), path)
}
pub fn build_merge_map(
all_ids: &[TaskId],
from: &HashSet<String>,
into: Option<&Token>,
) -> Vec<(TaskId, TaskId)> {
let mut ids: Vec<&TaskId> = all_ids
.iter()
.filter(|id| id.segments().is_some())
.collect();
ids.sort_by(|a, b| a.as_str().cmp(b.as_str()));
ids.dedup_by(|a, b| a.as_str() == b.as_str());
let into_width = if into.is_some() { 1 } else { 3 };
let max_len = ids
.iter()
.map(|id| id.segments().unwrap().1.len())
.max()
.unwrap_or(0);
let mut memo: HashMap<String, TaskId> = HashMap::new();
let mut counters: HashMap<(String, String), u32> = HashMap::new();
for tier in 1..=max_len {
let tier_ids = || {
ids.iter()
.copied()
.filter(move |id| id.segments().unwrap().1.len() == tier)
};
let mut preserved: Vec<&TaskId> = tier_ids().filter(|id| !last_in_from(id, from)).collect();
let mut substituted: Vec<&TaskId> =
tier_ids().filter(|id| last_in_from(id, from)).collect();
preserved.sort_by(|a, b| a.as_str().cmp(b.as_str()));
substituted.sort_by(|a, b| a.as_str().cmp(b.as_str()));
for id in preserved {
let (prefix, segs) = id.segments().unwrap();
let parent_new = parent_new_segments(id, &memo);
let last = segs.last().unwrap().clone();
if seg_is_into(&last, into) {
let key = context_key(prefix, &parent_new);
let e = counters.entry(key).or_insert(0);
*e = (*e).max(last.number);
}
let mut new_segs = parent_new;
new_segs.push(last);
memo.insert(
id.as_str().to_string(),
TaskId::from_segments(prefix, new_segs),
);
}
for id in substituted {
let (prefix, _segs) = id.segments().unwrap();
let parent_new = parent_new_segments(id, &memo);
let key = context_key(prefix, &parent_new);
let number = {
let e = counters.entry(key).or_insert(0);
*e += 1;
*e
};
let mut new_segs = parent_new;
new_segs.push(Segment {
token: into.cloned(),
number,
width: into_width,
});
memo.insert(
id.as_str().to_string(),
TaskId::from_segments(prefix, new_segs),
);
}
}
let mut out: Vec<(TaskId, TaskId)> = ids
.iter()
.filter_map(|id| {
memo.get(id.as_str())
.filter(|new| new.as_str() != id.as_str())
.map(|new| ((*id).clone(), new.clone()))
})
.collect();
out.sort_by(|a, b| a.0.as_str().cmp(b.0.as_str()));
out
}
pub fn collect_ids(tasks: &[Task], out: &mut Vec<TaskId>) {
for t in tasks {
if let Some(id) = &t.id {
out.push(id.clone());
}
collect_ids(&t.subtasks, out);
}
}
pub fn collect_ids_in_track(track: &Track, out: &mut Vec<TaskId>) {
for node in &track.nodes {
if let TrackNode::Section { tasks, .. } = node {
collect_ids(tasks, out);
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProseHit {
pub old: String,
pub new: String,
pub is_citation: bool,
pub context: String,
}
pub fn apply_map_to_tasks(
tasks: &mut [Task],
map: &HashMap<String, TaskId>,
rewrite_notes: bool,
hits: &mut Vec<ProseHit>,
) -> bool {
let mut any = false;
for t in tasks.iter_mut() {
let mut dirty = false;
if let Some(id) = &t.id
&& let Some(new) = map.get(id.as_str())
{
t.id = Some(new.clone());
dirty = true;
}
for m in &mut t.metadata {
match m {
Metadata::Dep(deps) => {
for d in deps.iter_mut() {
if let Some(new) = map.get(d.as_str()) {
*d = new.as_str().to_string();
dirty = true;
}
}
}
Metadata::Note(s) | Metadata::Spec(s) => {
let (new_text, found) = scan_prose(s, map, rewrite_notes);
if !found.is_empty() {
hits.extend(found);
if rewrite_notes && &new_text != s {
*s = new_text;
dirty = true;
}
}
}
Metadata::Ref(refs) => {
for r in refs.iter_mut() {
let (new_text, found) = scan_prose(r, map, rewrite_notes);
if !found.is_empty() {
hits.extend(found);
if rewrite_notes && &new_text != r {
*r = new_text;
dirty = true;
}
}
}
}
Metadata::Added(_) | Metadata::Resolved(_) => {}
}
}
if dirty {
t.mark_dirty();
any = true;
}
if apply_map_to_tasks(&mut t.subtasks, map, rewrite_notes, hits) {
any = true;
}
}
any
}
pub fn apply_map_to_track(
track: &mut Track,
map: &HashMap<String, TaskId>,
rewrite_notes: bool,
hits: &mut Vec<ProseHit>,
) -> bool {
let mut any = false;
for node in &mut track.nodes {
if let TrackNode::Section { tasks, .. } = node
&& apply_map_to_tasks(tasks, map, rewrite_notes, hits)
{
any = true;
}
}
any
}
fn scan_prose(text: &str, map: &HashMap<String, TaskId>, rewrite: bool) -> (String, Vec<ProseHit>) {
let mut hits = Vec::new();
let mut olds: Vec<&String> = map.keys().collect();
olds.sort_by(|a, b| b.len().cmp(&a.len()).then(a.as_str().cmp(b.as_str())));
let mut result = text.to_string();
for old in olds {
let new = map.get(old).unwrap().as_str();
let mut search_from = 0;
while let Some(rel) = result[search_from..].find(old.as_str()) {
let start = search_from + rel;
let end = start + old.len();
let before_ok = start == 0
|| !result.as_bytes()[start - 1].is_ascii_alphanumeric()
&& result.as_bytes()[start - 1] != b'-';
let after_ok = end >= result.len() || {
let bytes = result.as_bytes();
let c = bytes[end];
if c.is_ascii_alphanumeric() {
false
} else if c == b'.' {
!(end + 1 < bytes.len() && bytes[end + 1].is_ascii_digit())
} else {
true
}
};
if before_ok && after_ok {
let is_citation = looks_like_citation(&result, start, end);
hits.push(ProseHit {
old: old.clone(),
new: new.to_string(),
is_citation,
context: snippet(&result, start, end),
});
if rewrite && !is_citation {
result.replace_range(start..end, new);
search_from = start + new.len();
continue;
}
}
search_from = end;
}
}
(result, hits)
}
fn looks_like_citation(text: &str, start: usize, end: usize) -> bool {
let bytes = text.as_bytes();
if start > 0 && bytes[start - 1] == b'(' && end < bytes.len() && bytes[end] == b')' {
return true;
}
let window_start = start.saturating_sub(20);
let window = &text[window_start..start];
let mut run = 0usize;
for c in window.chars() {
if c.is_ascii_hexdigit() {
run += 1;
if run >= 7 {
return true;
}
} else {
run = 0;
}
}
false
}
fn snippet(text: &str, start: usize, end: usize) -> String {
let from = text[..start]
.rfind('\n')
.map(|i| i + 1)
.unwrap_or(0)
.max(start.saturating_sub(40));
let to = text[end..]
.find('\n')
.map(|i| end + i)
.unwrap_or(text.len())
.min(end + 40);
text[from..to].trim().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn ids(list: &[&str]) -> Vec<TaskId> {
list.iter().map(|s| TaskId::parse(s)).collect()
}
fn from(list: &[&str]) -> HashSet<String> {
list.iter().map(|s| s.to_string()).collect()
}
fn tok(s: &str) -> Token {
Token::new(s).unwrap()
}
fn map_of(pairs: &[(TaskId, TaskId)]) -> Vec<(String, String)> {
pairs
.iter()
.map(|(a, b)| (a.as_str().to_string(), b.as_str().to_string()))
.collect()
}
#[test]
fn top_level_renumber_continues_target_sequence() {
let all = ids(&["BAC-b1", "BAC-b2", "BAC-b3", "BAC-f1", "BAC-f2", "BAC-f3"]);
let out = build_merge_map(&all, &from(&["f"]), Some(&tok("b")));
assert_eq!(
map_of(&out),
vec![
("BAC-f1".into(), "BAC-b4".into()),
("BAC-f2".into(), "BAC-b5".into()),
("BAC-f3".into(), "BAC-b6".into()),
]
);
}
#[test]
fn merges_multiple_sources_into_one_target() {
let all = ids(&["SEC-b1", "SEC-d1", "SEC-f1"]);
let out = build_merge_map(&all, &from(&["d", "f"]), Some(&tok("b")));
assert_eq!(
map_of(&out),
vec![
("SEC-d1".into(), "SEC-b2".into()),
("SEC-f1".into(), "SEC-b3".into()),
]
);
}
#[test]
fn empty_target_namespace_starts_at_one() {
let all = ids(&["TOO-d1", "TOO-d2"]);
let out = build_merge_map(&all, &from(&["d"]), Some(&tok("b")));
assert_eq!(
map_of(&out),
vec![
("TOO-d1".into(), "TOO-b1".into()),
("TOO-d2".into(), "TOO-b2".into()),
]
);
}
#[test]
fn preserves_other_actors_child_segment() {
let all = ids(&["SEC-b1", "SEC-d1", "SEC-d1.a3"]);
let out = build_merge_map(&all, &from(&["d"]), Some(&tok("b")));
assert_eq!(
map_of(&out),
vec![
("SEC-d1".into(), "SEC-b2".into()),
("SEC-d1.a3".into(), "SEC-b2.a3".into()),
]
);
}
#[test]
fn preserves_null_child_under_merged_parent() {
let all = ids(&["EFF-d1", "EFF-d1.1", "EFF-d1.2"]);
let out = build_merge_map(&all, &from(&["d"]), Some(&tok("b")));
assert_eq!(
map_of(&out),
vec![
("EFF-d1".into(), "EFF-b1".into()),
("EFF-d1.1".into(), "EFF-b1.1".into()),
("EFF-d1.2".into(), "EFF-b1.2".into()),
]
);
}
#[test]
fn substituted_children_under_new_parent_dont_collide() {
let all = ids(&["EFF-f1", "EFF-f1.b3", "EFF-f1.d7"]);
let out = build_merge_map(&all, &from(&["d", "f"]), Some(&tok("b")));
assert_eq!(
map_of(&out),
vec![
("EFF-f1".into(), "EFF-b1".into()),
("EFF-f1.b3".into(), "EFF-b1.b3".into()),
("EFF-f1.d7".into(), "EFF-b1.b4".into()),
]
);
}
#[test]
fn untouched_ids_and_widths_are_preserved() {
let all = ids(&["EFF-014", "EFF-014.1", "EFF-d1"]);
let out = build_merge_map(&all, &from(&["d"]), Some(&tok("b")));
assert_eq!(map_of(&out), vec![("EFF-d1".into(), "EFF-b1".into())]);
}
#[test]
fn different_prefixes_have_independent_sequences() {
let all = ids(&["BAC-b5", "BAC-d1", "SEC-d1"]);
let out = build_merge_map(&all, &from(&["d"]), Some(&tok("b")));
assert_eq!(
map_of(&out),
vec![
("BAC-d1".into(), "BAC-b6".into()),
("SEC-d1".into(), "SEC-b1".into()),
]
);
}
#[test]
fn validate_rejects_bad_requests() {
assert!(validate_merge_request(&[], "b").is_err()); assert!(validate_merge_request(&["b".into()], "b").is_err()); assert!(validate_merge_request(&["null".into()], "b").is_err()); assert!(validate_merge_request(&["d".into()], "null").is_err()); assert!(validate_merge_request(&["d".into(), "d".into()], "b").is_err()); assert!(validate_merge_request(&["d".into(), "f".into()], "b").is_ok());
}
#[test]
fn prose_scan_matches_on_boundaries_only() {
let mut map: HashMap<String, TaskId> = HashMap::new();
map.insert("BAC-f1".into(), TaskId::parse("BAC-b17"));
let (out, hits) = scan_prose("see BAC-f1, not BAC-f10 nor BAC-f1.2", &map, true);
assert_eq!(hits.len(), 1);
assert_eq!(out, "see BAC-b17, not BAC-f10 nor BAC-f1.2");
}
#[test]
fn prose_scan_matches_id_before_a_sentence_period() {
let mut map: HashMap<String, TaskId> = HashMap::new();
map.insert("BAC-f1".into(), TaskId::parse("BAC-b6"));
let (out, hits) = scan_prose("captured as BAC-f1. See BAC-f1.2 later", &map, true);
assert_eq!(hits.len(), 1);
assert_eq!(out, "captured as BAC-b6. See BAC-f1.2 later");
}
#[test]
fn prose_scan_reports_but_skips_citations() {
let mut map: HashMap<String, TaskId> = HashMap::new();
map.insert("SEC-d1".into(), TaskId::parse("SEC-b2"));
let (out, hits) = scan_prose("added by 77071079 fix(SEC-d1)", &map, true);
assert_eq!(hits.len(), 1);
assert!(hits[0].is_citation);
assert_eq!(out, "added by 77071079 fix(SEC-d1)"); }
}