1use anyhow::{Context, anyhow};
4
5use crate::error::Result;
6use chrono::NaiveDate;
7use std::collections::BTreeMap;
8
9use crate::config::{Layout, VissueConfig};
10use crate::error::Error;
11use crate::graph::DependencyGraph;
12use crate::model::{IssueHeading, LogEntry, TODO_KEYWORDS, today_inactive_bracket};
13use crate::store::{
14 IssueDoc, collect_org_ids, detect_project_from_ctx, find_by_id, generate_id, load_all,
15 resolve_existing_project_case, with_issues_lock, with_issues_locks,
16};
17
18pub fn resolve_project(layout: &Layout, explicit: Option<&str>) -> Result<String> {
28 if let Some(p) = explicit {
29 if p.is_empty() {
30 return Err(anyhow!("--project given but empty").into());
31 }
32 return resolve_existing_project_case(layout, p);
33 }
34 let cwd = std::env::current_dir()?;
35 let detected = detect_project_from_ctx(&cwd).ok_or_else(|| {
36 anyhow!(
37 "no --project given and no .project-ctx.toml found walking up from {}",
38 cwd.display()
39 )
40 })?;
41 resolve_existing_project_case(layout, &detected)
42}
43
44#[derive(Debug, Default, Clone, Copy)]
46pub struct CreateOpts<'a> {
47 pub priority: Option<char>,
49 pub issue_type: Option<&'a str>,
51 pub deadline: Option<&'a str>,
53 pub scheduled: Option<&'a str>,
55 pub tags: Option<&'a str>,
57 pub parent: Option<&'a str>,
59 pub quiet: bool,
61 pub body: Option<&'a str>,
63 pub extra_ids: &'a [String],
66}
67
68pub fn create(layout: &Layout, project: &str, title: &str, opts: CreateOpts<'_>) -> Result<String> {
80 let project = resolve_existing_project_case(layout, project)?;
81 let cfg = VissueConfig::load(layout)?;
82 let priority = opts.priority.unwrap_or(cfg.issues.default_priority);
83 if !"ABC".contains(priority) {
84 return Err(anyhow!("invalid priority {priority:?}; allowed: A B C").into());
85 }
86 let path = layout.project_issues_path(&project);
87
88 let known_ids = if opts.parent.is_some() || opts.body.is_some() {
90 collect_org_ids(layout)?
91 } else {
92 std::collections::HashSet::new()
93 };
94 if let Some(p) = opts.parent
95 && !known_ids.contains(p)
96 {
97 return Err(anyhow!("--parent {p} does not refer to any known id").into());
98 }
99
100 with_issues_lock(&path, || {
101 let mut doc = IssueDoc::parse_file(&project, &path)?;
102 let mut taken = doc.known_ids();
103 taken.extend(opts.extra_ids.iter().cloned());
104 let id = generate_id(&project, &taken, cfg.issues.id_length)?;
105
106 let mut props = BTreeMap::new();
107 props.insert("ID".into(), id.clone());
108 props.insert("CREATED".into(), today_inactive_bracket());
109 if !props.contains_key("DISCOVERED_FROM")
110 && let Some(body) = opts.body
111 && let Some(origin) = first_existing_id_link(body, &known_ids)
112 {
113 props.insert("DISCOVERED_FROM".into(), origin);
114 }
115 if let Some(t) = opts.issue_type {
116 props.insert("TYPE".into(), t.into());
117 }
118 if let Some(d) = opts.deadline {
119 validate_org_date(d)?;
120 props.insert("DEADLINE".into(), d.into());
121 }
122 if let Some(s) = opts.scheduled {
123 validate_org_date(s)?;
124 props.insert("SCHEDULED".into(), s.into());
125 }
126 let mut org_tags: Vec<String> = Vec::new();
130 if let Some(tags) = opts.tags {
131 let mut property_tags: Vec<String> = Vec::new();
132 for tag in tags.split([',', ':']).map(str::trim) {
133 if tag.is_empty() {
134 continue;
135 }
136 if tag.chars().all(crate::model::is_org_tag_char) {
137 if !org_tags.iter().any(|seen| seen == tag) {
138 org_tags.push(tag.to_string());
139 }
140 } else if !property_tags.iter().any(|seen| seen == tag) {
141 property_tags.push(tag.to_string());
142 }
143 }
144 if !property_tags.is_empty() {
145 props.insert(crate::model::TAGS_PROPERTY.into(), property_tags.join(","));
146 }
147 }
148 if let Some(p) = opts.parent {
149 props.insert("PARENT".into(), p.into());
150 }
151
152 doc.headings.push(IssueHeading {
153 id: id.clone(),
154 title: title.to_string(),
155 state: "TODO".into(),
156 priority,
157 properties: props,
158 org_tags,
159 property_order: Vec::new(),
160 body: match opts.body {
161 Some(b) if !b.trim().is_empty() => format!("{}\n", b.trim_end()),
162 _ => String::new(),
163 },
164 logbook: Vec::new(),
165 line_start: 0,
166 line_end: 0,
167 });
168 doc.write()?;
169
170 if opts.quiet {
171 Ok(format!("{id}\n"))
172 } else {
173 Ok(format!(
174 "{id} TODO [#{priority}] {title}\nfile: {}\n",
175 path.display()
176 ))
177 }
178 })
179}
180
181pub(crate) fn validate_org_date(s: &str) -> Result<()> {
182 let inner = s
183 .trim_start_matches(['<', '['])
184 .trim_end_matches(['>', ']']);
185 let token = inner.split_whitespace().next().unwrap_or("");
186 NaiveDate::parse_from_str(token, "%Y-%m-%d").with_context(|| {
187 format!("expected org date like <YYYY-MM-DD> or [YYYY-MM-DD], got {s:?}")
188 })?;
189 Ok(())
190}
191
192pub fn update(
200 layout: &Layout,
201 id: &str,
202 new_state: Option<&str>,
203 new_priority: Option<char>,
204 block_add: Option<&str>,
205 block_clear: Option<&str>,
206) -> Result<UpdateOutcome> {
207 let identity = crate::config::identity(layout);
208 update_as(
209 layout,
210 id,
211 new_state,
212 new_priority,
213 block_add,
214 block_clear,
215 &identity,
216 )
217}
218
219#[derive(Debug, Default, Clone, Copy)]
224pub struct UpdatePred<'a> {
225 pub if_state: Option<&'a str>,
227 pub if_gen: Option<u64>,
229}
230
231pub fn update_pred(
237 layout: &Layout,
238 id: &str,
239 new_state: Option<&str>,
240 new_priority: Option<char>,
241 block_add: Option<&str>,
242 block_clear: Option<&str>,
243 pred: UpdatePred<'_>,
244) -> Result<UpdateOutcome> {
245 let identity = crate::config::identity(layout);
246 update_as_pred(
247 layout,
248 id,
249 new_state,
250 new_priority,
251 block_add,
252 block_clear,
253 &identity,
254 pred,
255 )
256}
257
258pub fn update_as(
265 layout: &Layout,
266 id: &str,
267 new_state: Option<&str>,
268 new_priority: Option<char>,
269 block_add: Option<&str>,
270 block_clear: Option<&str>,
271 identity: &str,
272) -> Result<UpdateOutcome> {
273 update_as_pred(
274 layout,
275 id,
276 new_state,
277 new_priority,
278 block_add,
279 block_clear,
280 identity,
281 UpdatePred::default(),
282 )
283}
284
285#[allow(clippy::too_many_arguments)]
291pub fn update_as_pred(
292 layout: &Layout,
293 id: &str,
294 new_state: Option<&str>,
295 new_priority: Option<char>,
296 block_add: Option<&str>,
297 block_clear: Option<&str>,
298 identity: &str,
299 pred: UpdatePred<'_>,
300) -> Result<UpdateOutcome> {
301 let (_h0, path, project) =
302 find_by_id(layout, id)?.ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
303
304 let (transition, changed) = with_issues_lock(&path, || {
305 let graph = if block_add.is_some() {
308 Some(DependencyGraph::from_issues(&load_all(layout)?)?)
309 } else {
310 None
311 };
312 let mut doc = IssueDoc::parse_file(&project, &path)?;
313 let h = doc
314 .headings
315 .iter_mut()
316 .find(|x| x.id == id)
317 .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
318
319 let original = h.state.clone();
320 let mut changed = Vec::new();
321
322 if pred.if_state.is_some() || pred.if_gen.is_some() {
323 let seen = crate::events::generation(layout);
324 if let Some(want) = pred.if_state {
325 if !TODO_KEYWORDS.contains(&want) {
326 return Err(
327 anyhow!("invalid --if-state {want:?}; allowed: {TODO_KEYWORDS:?}").into(),
328 );
329 }
330 if h.state != want {
331 return Err(Error::StaleWrite {
332 id: id.to_string(),
333 expected_state: Some(want.to_string()),
334 actual_state: h.state.clone(),
335 expected_gen: pred.if_gen,
336 actual_gen: Some(seen),
337 });
338 }
339 }
340 if let Some(want_gen) = pred.if_gen
341 && seen != want_gen
342 {
343 return Err(Error::StaleWrite {
344 id: id.to_string(),
345 expected_state: pred.if_state.map(str::to_string),
346 actual_state: h.state.clone(),
347 expected_gen: Some(want_gen),
348 actual_gen: Some(seen),
349 });
350 }
351 }
352
353 if let Some(s) = new_state {
354 if !TODO_KEYWORDS.contains(&s) {
355 return Err(anyhow!("invalid state {s:?}; allowed: {TODO_KEYWORDS:?}").into());
356 }
357 if h.state != s {
358 if is_terminal(&h.state) && is_terminal(s) {
359 record_sibling_terminal(h, s);
360 changed.push(format!("sibling terminal {s} (held {})", h.state));
361 } else {
362 let from = h.state.clone();
363 h.record_state_change(s);
364 changed.push(format!("state {from} -> {s}"));
365 for note in settle_claim(h, &from, s, identity) {
366 changed.push(note);
367 }
368 }
369 }
370 }
371
372 if let Some(p) = new_priority {
373 if !"ABC".contains(p) {
374 return Err(anyhow!("invalid priority {p:?}; allowed: A B C").into());
375 }
376 if h.priority != p {
377 h.priority = p;
378 changed.push(format!("priority -> [#{p}]"));
379 }
380 }
381
382 if let Some(blk) = block_add {
383 let mut current = h.blocked_by();
384 if !current.iter().any(|x| x == blk) {
385 if let Some(graph) = &graph {
386 graph.accepts_edge(blk, id)?;
387 }
388 current.push(blk.to_string());
389 h.properties.insert("BLOCKED_BY".into(), current.join(","));
390 if h.state == "TODO" || h.state == "STARTED" {
391 let from = h.state.clone();
392 h.record_state_change("BLOCKED");
393 changed.push(format!("state {from} -> BLOCKED (auto on block)"));
394 }
395 changed.push(format!("blocked_by += {blk}"));
396 }
397 }
398
399 if let Some(blk) = block_clear {
400 let mut current = h.blocked_by();
401 let before = current.len();
402 current.retain(|x| x != blk);
403 if current.len() < before {
404 if current.is_empty() {
405 h.properties.remove("BLOCKED_BY");
406 if h.state == "BLOCKED" {
407 let from = h.state.clone();
408 h.record_state_change("TODO");
409 changed.push("state BLOCKED -> TODO (auto on unblock)".to_string());
410 for note in settle_claim(h, &from, "TODO", identity) {
411 changed.push(note);
412 }
413 }
414 } else {
415 h.properties.insert("BLOCKED_BY".into(), current.join(","));
416 }
417 changed.push(format!("blocked_by -= {blk}"));
418 }
419 }
420
421 if changed.is_empty() {
422 return Ok((None, Vec::new()));
423 }
424
425 let final_state = h.state.clone();
426 doc.write()?;
427 let transition = (original != final_state).then_some((original, final_state));
428 Ok((transition, changed))
429 })?;
430
431 if changed.is_empty() {
432 return Ok(UpdateOutcome {
433 report: format!("{id}: no change\n"),
434 hints: Vec::new(),
435 });
436 }
437
438 if let Some((from, to)) = &transition {
439 let _ = crate::events::emit_state_change(layout, &project, id, from, to);
440 }
441
442 let mut hints = Vec::new();
443 if matches!(
444 transition.as_ref().map(|(_, to)| to.as_str()),
445 Some("DONE") | Some("CANCELLED")
446 ) {
447 for (other_project, other) in load_all(layout)? {
448 if !other.blocked_by().iter().any(|b| b == id) {
449 continue;
450 }
451 if other.state == "DONE" || other.state == "CANCELLED" {
452 continue;
453 }
454 hints.push(format!(
455 "{} (in {}) lists this as a blocker; clear with `vissue update {} --unblock {}`",
456 other.id, other_project, other.id, id
457 ));
458 }
459 }
460 Ok(UpdateOutcome {
461 report: format!("{id}: {}\n", changed.join(", ")),
462 hints,
463 })
464}
465
466fn keeps_claim(state: &str) -> bool {
469 matches!(state, "STARTED" | "BLOCKED")
470}
471
472fn is_terminal(state: &str) -> bool {
473 matches!(state, "DONE" | "CANCELLED")
474}
475
476fn record_sibling_terminal(h: &mut IssueHeading, attempted: &str) {
477 h.properties
478 .insert("SIBLING_TERMINAL".into(), attempted.to_string());
479}
480
481pub fn resolve_terminal(layout: &Layout, id: &str, state: &str) -> Result<String> {
488 if !is_terminal(state) {
489 return Err(anyhow!("resolve state must be DONE or CANCELLED, got {state:?}").into());
490 }
491 let identity = crate::config::identity(layout);
492 let (_h0, path, project) =
493 find_by_id(layout, id)?.ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
494 let from = with_issues_lock(&path, || {
495 let mut doc = IssueDoc::parse_file(&project, &path)?;
496 let h = doc
497 .headings
498 .iter_mut()
499 .find(|x| x.id == id)
500 .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
501 let from = h.state.clone();
502 if from != state {
503 h.record_state_change(state);
504 settle_claim(h, &from, state, &identity);
505 }
506 h.properties.remove("SIBLING_TERMINAL");
507 doc.write()?;
508 Ok(from)
509 })?;
510 if from != state {
511 let _ = crate::events::emit_state_change(layout, &project, id, &from, state);
512 }
513 Ok(format!("resolved {id} -> {state}\n"))
514}
515
516fn settle_claim(h: &mut IssueHeading, from: &str, to: &str, identity: &str) -> Vec<String> {
522 let mut notes = Vec::new();
523 if to == "STARTED" && h.claimed_by().is_none() {
524 h.set_claim(identity);
525 notes.push(format!("claimed by {identity}"));
526 } else if keeps_claim(from)
527 && !keeps_claim(to)
528 && let Some((who, _when)) = h.release_claim()
529 {
530 notes.push(format!("claim released ({who})"));
531 }
532 notes
533}
534
535pub fn claim(layout: &Layout, id: &str, force: bool) -> Result<String> {
546 let identity = crate::config::identity(layout);
547 claim_as(layout, id, force, &identity)
548}
549
550pub fn claim_as(layout: &Layout, id: &str, force: bool, identity: &str) -> Result<String> {
558 let (_h0, path, project) =
559 find_by_id(layout, id)?.ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
560
561 let report = with_issues_lock(&path, || {
562 let mut doc = IssueDoc::parse_file(&project, &path)?;
563 let h = doc
564 .headings
565 .iter_mut()
566 .find(|x| x.id == id)
567 .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
568
569 if h.state == "DONE" || h.state == "CANCELLED" {
570 return Err(Error::InvalidState {
571 id: id.to_string(),
572 state: h.state.clone(),
573 });
574 }
575 if let Some(holder) = h.claimed_by() {
576 if holder != identity && !force {
577 return Err(Error::ClaimConflict {
578 id: id.to_string(),
579 holder: holder.to_string(),
580 claimed_at: h.claimed_at().map(str::to_string),
581 });
582 }
583 if holder != identity {
584 let previous = holder.to_string();
585 let from = h.state.clone();
586 h.release_claim();
587 h.set_claim(identity);
588 h.record_state_change("STARTED");
589 doc.write()?;
590 if from != "STARTED" {
591 let _ =
592 crate::events::emit_state_change(layout, &project, id, &from, "STARTED");
593 }
594 return Ok(format!("claimed {id} (taken over from {previous})\n"));
595 }
596 }
597
598 let was = h.state.clone();
599 h.record_state_change("STARTED");
600 if h.claimed_by().is_none() {
601 h.set_claim(identity);
602 }
603 doc.write()?;
604 if was != "STARTED" {
605 let _ = crate::events::emit_state_change(layout, &project, id, &was, "STARTED");
606 }
607 if was == "STARTED" {
608 Ok(format!("claimed {id} by {identity}\n"))
609 } else {
610 Ok(format!("claimed {id} by {identity} ({was} -> STARTED)\n"))
611 }
612 })?;
613 Ok(report)
614}
615
616#[derive(Debug, Clone)]
618pub struct UpdateOutcome {
619 pub report: String,
621 pub hints: Vec<String>,
623}
624
625pub fn note(layout: &Layout, id: &str, text: &str) -> Result<String> {
634 let text = text
637 .split_whitespace()
638 .collect::<Vec<_>>()
639 .join(" ")
640 .replace('"', "'");
641 if text.is_empty() {
642 return Err(anyhow!("note text is empty").into());
643 }
644 let (_h0, path, project) =
645 find_by_id(layout, id)?.ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
646 with_issues_lock(&path, || {
647 let mut doc = IssueDoc::parse_file(&project, &path)?;
648 let h = doc
649 .headings
650 .iter_mut()
651 .find(|x| x.id == id)
652 .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
653 h.logbook.insert(
656 0,
657 LogEntry {
658 timestamp: LogEntry::now(),
659 from_state: None,
660 to_state: None,
661 note: Some(text.clone()),
662 raw: None,
663 },
664 );
665 doc.write()?;
666 Ok(format!("{id}: noted\n"))
667 })
668}
669
670pub fn append_body(layout: &Layout, id: &str, text: &str) -> Result<String> {
685 append_body_as(layout, id, text, &crate::config::identity(layout))
686}
687
688pub fn append_body_as(layout: &Layout, id: &str, text: &str, identity: &str) -> Result<String> {
695 let text = text.trim_end();
696 if text.trim().is_empty() {
697 return Err(anyhow!("append text is empty").into());
698 }
699 let (_h0, path, project) =
700 find_by_id(layout, id)?.ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
701 with_issues_lock(&path, || {
702 let mut doc = IssueDoc::parse_file(&project, &path)?;
703 let h = doc
704 .headings
705 .iter_mut()
706 .find(|x| x.id == id)
707 .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
708 let stamp = format!("{} {identity}", today_inactive_bracket());
709 if !h.body.trim().is_empty() {
710 h.body = h.body.trim_end().to_string();
711 h.body.push_str("\n\n");
712 } else {
713 h.body.clear();
714 }
715 h.body.push_str(&stamp);
716 h.body.push('\n');
717 h.body.push_str(text);
718 h.body.push('\n');
719 doc.write()?;
720 let lines = text.lines().count();
721 Ok(format!("{id}: appended {lines} line(s)\n"))
722 })
723}
724
725pub fn fold(layout: &Layout, inbox: &std::path::Path, project: &str) -> Result<String> {
739 let project = resolve_existing_project_case(layout, project)?;
740 let text = std::fs::read_to_string(inbox)
741 .with_context(|| format!("read inbox {}", inbox.display()))?;
742 let lines: Vec<String> = text.lines().map(str::to_string).collect();
743
744 struct Entry {
745 line: usize,
746 title: String,
747 body: String,
748 stamped: bool,
749 }
750 let mut entries: Vec<Entry> = Vec::new();
751 let mut i = 0;
752 while i < lines.len() {
753 if let Some(title) = lines[i].strip_prefix("* TODO ") {
754 let start = i + 1;
755 let end = lines[start..]
756 .iter()
757 .position(|l| l.starts_with("* "))
758 .map(|off| start + off)
759 .unwrap_or(lines.len());
760 let stamped = lines[start..end]
761 .iter()
762 .any(|l| l.trim_start().starts_with(":VISSUE_ID:"));
763 let body = lines[start..end].join("\n").trim().to_string();
764 entries.push(Entry {
765 line: i,
766 title: title.trim().to_string(),
767 body,
768 stamped,
769 });
770 i = end;
771 } else {
772 i += 1;
773 }
774 }
775
776 let mut out = lines.clone();
779 let mut created: Vec<String> = Vec::new();
780 let mut failure = None;
781 for e in entries.iter().rev() {
782 if e.stamped {
783 continue;
784 }
785 let printed = create(
786 layout,
787 &project,
788 &e.title,
789 CreateOpts {
790 quiet: true,
791 body: if e.body.is_empty() {
792 None
793 } else {
794 Some(&e.body)
795 },
796 ..CreateOpts::default()
797 },
798 );
799 let id = match printed {
800 Ok(printed) => printed.trim().to_string(),
801 Err(e) => {
802 failure = Some(e);
806 break;
807 }
808 };
809 out[e.line] = format!("* DONE {}", e.title);
810 out.insert(e.line + 1, format!(":VISSUE_ID: {id}"));
811 created.push(id);
812 }
813 created.reverse();
814
815 if !created.is_empty() {
816 let mut rendered = out.join("\n");
817 if text.ends_with('\n') {
818 rendered.push('\n');
819 }
820 std::fs::write(inbox, rendered)
821 .with_context(|| format!("write inbox {}", inbox.display()))?;
822 }
823 if let Some(error) = failure {
824 return Err(crate::error::Error::Other(
825 anyhow::Error::from(error).context(format!(
826 "folded {} before failing: {}",
827 created.len(),
828 created.join(" ")
829 )),
830 ));
831 }
832 if created.is_empty() {
833 return Ok("folded 0 (nothing unstamped)\n".into());
834 }
835 Ok(format!("folded {}: {}\n", created.len(), created.join(" ")))
836}
837
838pub fn refile(layout: &Layout, id: &str, to_project: &str) -> Result<String> {
846 refile_to(layout, id, layout, to_project)
847}
848
849pub fn refile_to(
858 layout: &Layout,
859 id: &str,
860 dst_layout: &Layout,
861 to_project: &str,
862) -> Result<String> {
863 let to_project = resolve_existing_project_case(dst_layout, to_project)?;
864 let target_path = dst_layout.project_issues_path(&to_project);
865 let (_heading, src_path, src_project) =
866 find_by_id(layout, id)?.ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
867 if src_path == target_path {
868 return Ok(format!("{id} already in {to_project}; nothing to do\n"));
869 }
870 with_issues_locks(&[&src_path, &target_path], || {
871 let mut src_doc = IssueDoc::parse_file(&src_project, &src_path)?;
872 let heading = src_doc
873 .remove(id)
874 .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
875
876 let mut tgt_doc = IssueDoc::parse_file(&to_project, &target_path)?;
882 tgt_doc.upsert(heading);
883 tgt_doc.write()?;
884 src_doc.write()?;
885 Ok(())
886 })?;
887 Ok(format!("{id}: {src_project} -> {to_project}\n"))
888}
889
890#[derive(Debug, Default, Clone, Copy)]
892pub struct RejectOpts<'a> {
893 pub to: Option<&'a str>,
895 pub project: Option<&'a str>,
897 pub title: Option<&'a str>,
899 pub reason: Option<&'a str>,
901 pub dst_layout: Option<&'a Layout>,
903 pub dst_extra_ids: &'a [String],
906}
907
908pub fn reject(layout: &Layout, src: &str, opts: RejectOpts<'_>) -> Result<String> {
921 let identity = crate::config::identity(layout);
922 let (src0, src_path, src_project) =
923 find_by_id(layout, src)?.ok_or_else(|| Error::IssueNotFound {
924 id: src.to_string(),
925 })?;
926
927 let dst_layout = opts.dst_layout.unwrap_or(layout);
928 let existing_dst = if let Some(to) = opts.to {
929 if to == src {
930 return Err(anyhow!("reject destination cannot be the source {src}").into());
931 }
932 Some(
933 find_by_id(dst_layout, to)?
934 .ok_or_else(|| Error::IssueNotFound { id: to.to_string() })?,
935 )
936 } else {
937 None
938 };
939
940 let creating = existing_dst.is_none();
941 if creating && opts.project.is_none() {
942 return Err(anyhow!("reject needs --to DST or --project to create a successor").into());
943 }
944
945 let dst_project = if let Some((_, _, ref project)) = existing_dst {
946 project.clone()
947 } else {
948 resolve_existing_project_case(dst_layout, opts.project.unwrap_or(&src_project))?
949 };
950 let dst_path = dst_layout.project_issues_path(&dst_project);
951 let dst_title = opts.title.unwrap_or(src0.title.as_str());
952 let cfg = VissueConfig::load(layout)?;
953
954 let (dst_id, old_state, new_state) = with_issues_locks(&[&src_path, &dst_path], || {
955 if src_path == dst_path {
956 let mut doc = IssueDoc::parse_file(&src_project, &src_path)?;
957 let dst_id = if creating {
958 push_successor(
959 &mut doc,
960 &dst_project,
961 dst_title,
962 src,
963 &cfg,
964 opts.dst_extra_ids,
965 )?
966 } else {
967 let to = reject_to(opts)?;
968 set_discovered_from_if_empty(&mut doc, to, src)?;
969 to.to_string()
970 };
971 let (old_state, new_state) =
972 cancel_and_pivot(&mut doc, src, &dst_id, opts.reason, &identity)?;
973 doc.write()?;
974 Ok((dst_id, old_state, new_state))
975 } else {
976 let mut src_doc = IssueDoc::parse_file(&src_project, &src_path)?;
977 let mut dst_doc = IssueDoc::parse_file(&dst_project, &dst_path)?;
978 let dst_id = if creating {
979 push_successor(
980 &mut dst_doc,
981 &dst_project,
982 dst_title,
983 src,
984 &cfg,
985 opts.dst_extra_ids,
986 )?
987 } else {
988 let to = reject_to(opts)?;
989 set_discovered_from_if_empty(&mut dst_doc, to, src)?;
990 to.to_string()
991 };
992 let (old_state, new_state) =
993 cancel_and_pivot(&mut src_doc, src, &dst_id, opts.reason, &identity)?;
994 dst_doc.write()?;
995 src_doc.write()?;
996 Ok((dst_id, old_state, new_state))
997 }
998 })?;
999
1000 if old_state != new_state {
1001 let _ = crate::events::emit_state_change(layout, &src_project, src, &old_state, &new_state);
1002 }
1003 Ok(format!("rejected {src} -> {dst_id}\n"))
1004}
1005
1006fn reject_to(opts: RejectOpts<'_>) -> Result<&str> {
1007 opts.to
1008 .ok_or_else(|| anyhow!("reject destination missing after --to was required").into())
1009}
1010
1011fn push_successor(
1012 doc: &mut IssueDoc,
1013 project: &str,
1014 title: &str,
1015 src: &str,
1016 cfg: &VissueConfig,
1017 extra_ids: &[String],
1018) -> Result<String> {
1019 let mut taken = doc.known_ids();
1020 taken.extend(extra_ids.iter().cloned());
1021 let id = generate_id(project, &taken, cfg.issues.id_length)?;
1022 let mut props = BTreeMap::new();
1023 props.insert("ID".into(), id.clone());
1024 props.insert("CREATED".into(), today_inactive_bracket());
1025 props.insert("DISCOVERED_FROM".into(), src.to_string());
1026 doc.headings.push(IssueHeading {
1027 id: id.clone(),
1028 title: title.to_string(),
1029 state: "TODO".into(),
1030 priority: cfg.issues.default_priority,
1031 properties: props,
1032 org_tags: Vec::new(),
1033 property_order: Vec::new(),
1034 body: String::new(),
1035 logbook: Vec::new(),
1036 line_start: 0,
1037 line_end: 0,
1038 });
1039 Ok(id)
1040}
1041
1042fn set_discovered_from_if_empty(doc: &mut IssueDoc, id: &str, src: &str) -> Result<()> {
1043 let h = doc
1044 .headings
1045 .iter_mut()
1046 .find(|h| h.id == id)
1047 .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
1048 let empty = h
1049 .properties
1050 .get("DISCOVERED_FROM")
1051 .is_none_or(|s| s.trim().is_empty());
1052 if empty {
1053 h.properties
1054 .insert("DISCOVERED_FROM".into(), src.to_string());
1055 }
1056 Ok(())
1057}
1058
1059fn cancel_and_pivot(
1060 doc: &mut IssueDoc,
1061 src: &str,
1062 dst: &str,
1063 reason: Option<&str>,
1064 identity: &str,
1065) -> Result<(String, String)> {
1066 let h = doc
1067 .headings
1068 .iter_mut()
1069 .find(|h| h.id == src)
1070 .ok_or_else(|| Error::IssueNotFound {
1071 id: src.to_string(),
1072 })?;
1073 let old_state = h.state.clone();
1074 if is_terminal(&old_state) && old_state != "CANCELLED" {
1075 record_sibling_terminal(h, "CANCELLED");
1076 } else if old_state != "CANCELLED" {
1077 h.record_state_change("CANCELLED");
1078 settle_claim(h, &old_state, "CANCELLED", identity);
1079 }
1080 h.properties.insert("PIVOTED_TO".into(), dst.to_string());
1081 if let Some(reason) = reason {
1082 append_reason(h, reason, identity);
1083 }
1084 Ok((old_state, h.state.clone()))
1085}
1086
1087fn append_reason(h: &mut IssueHeading, text: &str, identity: &str) {
1088 let text = text.trim_end();
1089 if text.trim().is_empty() {
1090 return;
1091 }
1092 let stamp = format!("{} {identity}", today_inactive_bracket());
1093 if !h.body.trim().is_empty() {
1094 h.body = h.body.trim_end().to_string();
1095 h.body.push_str("\n\n");
1096 } else {
1097 h.body.clear();
1098 }
1099 h.body.push_str(&stamp);
1100 h.body.push('\n');
1101 h.body.push_str(text);
1102 h.body.push('\n');
1103}
1104
1105fn first_existing_id_link(body: &str, known: &std::collections::HashSet<String>) -> Option<String> {
1107 let mut rest = body;
1108 while let Some(start) = rest.find("[[") {
1109 let after_start = &rest[start + 2..];
1110 let end = after_start.find("]]")?;
1111 let raw = &after_start[..end];
1112 let target = raw.split_once("][").map_or(raw, |(target, _)| target);
1113 let target = target.trim();
1114 if let Some(id) = target.strip_prefix("id:") {
1115 let id = id.trim();
1116 if known.contains(id) {
1117 return Some(id.to_string());
1118 }
1119 }
1120 rest = &after_start[end + 2..];
1121 }
1122 None
1123}
1124
1125#[cfg(test)]
1126mod tests {
1127 use super::*;
1128 use crate::config::DEFAULT_PREFIX;
1129 use std::fs;
1130 use std::path::Path;
1131
1132 fn fresh_layout(dir: &Path) -> Layout {
1133 fs::create_dir_all(dir.join(DEFAULT_PREFIX)).unwrap();
1134 Layout::new(dir, DEFAULT_PREFIX)
1135 }
1136
1137 fn issue_at(layout: &Layout, project: &str, id: &str) -> IssueHeading {
1138 IssueDoc::parse_file(project, &layout.project_issues_path(project))
1139 .unwrap()
1140 .headings
1141 .into_iter()
1142 .find(|h| h.id == id)
1143 .expect("issue not found")
1144 }
1145
1146 fn only_id(layout: &Layout, project: &str) -> String {
1147 IssueDoc::parse_file(project, &layout.project_issues_path(project))
1148 .unwrap()
1149 .headings[0]
1150 .id
1151 .clone()
1152 }
1153
1154 #[test]
1155 fn create_rejects_a_parent_that_does_not_exist() {
1156 let dir = tempfile::tempdir().unwrap();
1157 let layout = fresh_layout(dir.path());
1158 let err = create(
1159 &layout,
1160 "sample",
1161 "child without parent",
1162 CreateOpts {
1163 parent: Some("sample-zzz9"),
1164 ..Default::default()
1165 },
1166 )
1167 .unwrap_err();
1168 assert!(err.to_string().contains("does not refer to any known id"));
1169 }
1170
1171 #[test]
1172 fn create_accepts_a_parent_defined_in_a_design_document() {
1173 let dir = tempfile::tempdir().unwrap();
1174 let layout = fresh_layout(dir.path());
1175 let parent_id = "sample-spec-20260615";
1176 let project_dir = layout.projects_dir().join("sample");
1177 fs::create_dir_all(&project_dir).unwrap();
1178 fs::write(
1179 project_dir.join("design.org"),
1180 format!("#+TITLE: sample design\n\n* Design\n:PROPERTIES:\n:ID: {parent_id}\n:END:\n"),
1181 )
1182 .unwrap();
1183
1184 create(
1185 &layout,
1186 "sample",
1187 "child under design",
1188 CreateOpts {
1189 parent: Some(parent_id),
1190 ..Default::default()
1191 },
1192 )
1193 .unwrap();
1194 assert!(only_id(&layout, "sample").starts_with("sample-"));
1195 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1196 assert_eq!(doc.headings[0].parent(), Some(parent_id));
1197 }
1198
1199 #[test]
1200 fn a_state_update_writes_a_logbook_entry() {
1201 let dir = tempfile::tempdir().unwrap();
1202 let layout = fresh_layout(dir.path());
1203 create(&layout, "sample", "first", CreateOpts::default()).unwrap();
1204 let id = only_id(&layout, "sample");
1205 update(&layout, &id, Some("STARTED"), None, None, None).unwrap();
1206 let h = issue_at(&layout, "sample", &id);
1207 assert_eq!(h.state, "STARTED");
1208 assert_eq!(h.logbook[0].from_state.as_deref(), Some("TODO"));
1209 assert_eq!(h.logbook[0].to_state.as_deref(), Some("STARTED"));
1210 }
1211
1212 #[test]
1213 fn blocking_and_unblocking_drive_the_state() {
1214 let dir = tempfile::tempdir().unwrap();
1215 let layout = fresh_layout(dir.path());
1216 create(&layout, "sample", "first", CreateOpts::default()).unwrap();
1217 create(&layout, "sample", "blocker", CreateOpts::default()).unwrap();
1218 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1219 let first = doc.headings[0].id.clone();
1220 let blocker = doc.headings[1].id.clone();
1221
1222 update(&layout, &first, None, None, Some(&blocker), None).unwrap();
1223 let h = issue_at(&layout, "sample", &first);
1224 assert_eq!(h.state, "BLOCKED");
1225 assert!(h.blocked_by().contains(&blocker));
1226
1227 update(&layout, &first, None, None, None, Some(&blocker)).unwrap();
1228 let h = issue_at(&layout, "sample", &first);
1229 assert_eq!(h.state, "TODO");
1230 assert!(h.blocked_by().is_empty());
1231 }
1232
1233 #[test]
1234 fn auto_unblock_to_todo_releases_the_claim() {
1235 let dir = tempfile::tempdir().unwrap();
1236 let layout = fresh_layout(dir.path());
1237 create(&layout, "sample", "first", CreateOpts::default()).unwrap();
1238 create(&layout, "sample", "blocker", CreateOpts::default()).unwrap();
1239 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1240 let first = doc.headings[0].id.clone();
1241 let blocker = doc.headings[1].id.clone();
1242
1243 crate::agent::claim(&layout, &first, false).unwrap();
1244 update(&layout, &first, None, None, Some(&blocker), None).unwrap();
1245 assert!(issue_at(&layout, "sample", &first).claimed_by().is_some());
1246
1247 update(&layout, &first, None, None, None, Some(&blocker)).unwrap();
1248 let h = issue_at(&layout, "sample", &first);
1249 assert_eq!(h.state, "TODO");
1250 assert!(h.claimed_by().is_none(), "claim stuck on TODO: {h:?}");
1251 }
1252
1253 #[test]
1254 fn blocker_cycle_is_rejected_before_writing() {
1255 let dir = tempfile::tempdir().unwrap();
1256 let layout = fresh_layout(dir.path());
1257 create(&layout, "sample", "first", CreateOpts::default()).unwrap();
1258 create(&layout, "sample", "second", CreateOpts::default()).unwrap();
1259 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1260 let first = doc.headings[0].id.clone();
1261 let second = doc.headings[1].id.clone();
1262
1263 update(&layout, &first, None, None, Some(&second), None).unwrap();
1264 let err = update(&layout, &second, None, None, Some(&first), None).unwrap_err();
1265 assert!(err.to_string().contains("blocker cycle"), "{err}");
1266 assert!(issue_at(&layout, "sample", &second).blocked_by().is_empty());
1267 }
1268
1269 #[test]
1270 fn closing_a_blocker_reports_the_issues_still_pointing_at_it() {
1271 let dir = tempfile::tempdir().unwrap();
1272 let layout = fresh_layout(dir.path());
1273 create(&layout, "sample", "first", CreateOpts::default()).unwrap();
1274 create(&layout, "sample", "blocker", CreateOpts::default()).unwrap();
1275 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1276 let first = doc.headings[0].id.clone();
1277 let blocker = doc.headings[1].id.clone();
1278 update(&layout, &first, None, None, Some(&blocker), None).unwrap();
1279
1280 let outcome = update(&layout, &blocker, Some("DONE"), None, None, None).unwrap();
1281 assert_eq!(outcome.hints.len(), 1, "{:?}", outcome.hints);
1282 assert!(outcome.hints[0].contains(&first), "{:?}", outcome.hints);
1283 }
1284
1285 #[test]
1286 fn refile_moves_the_heading_between_projects() {
1287 let dir = tempfile::tempdir().unwrap();
1288 let layout = fresh_layout(dir.path());
1289 create(&layout, "source", "the issue", CreateOpts::default()).unwrap();
1290 let id = only_id(&layout, "source");
1291 refile(&layout, &id, "target").unwrap();
1292
1293 let src = IssueDoc::parse_file("source", &layout.project_issues_path("source")).unwrap();
1294 let tgt = IssueDoc::parse_file("target", &layout.project_issues_path("target")).unwrap();
1295 assert!(src.headings.is_empty());
1296 assert_eq!(tgt.headings[0].id, id);
1297 }
1298
1299 #[test]
1300 fn deadlines_must_parse_as_org_dates() {
1301 let dir = tempfile::tempdir().unwrap();
1302 let layout = fresh_layout(dir.path());
1303 let err = create(
1304 &layout,
1305 "sample",
1306 "bad date",
1307 CreateOpts {
1308 deadline: Some("not-a-date"),
1309 ..Default::default()
1310 },
1311 )
1312 .unwrap_err();
1313 assert!(err.to_string().contains("expected org date"));
1314
1315 for (i, d) in ["<2026-05-15 Fri>", "[2026-05-15]"].iter().enumerate() {
1316 create(
1317 &layout,
1318 "sample",
1319 &format!("issue {i}"),
1320 CreateOpts {
1321 deadline: Some(d),
1322 ..Default::default()
1323 },
1324 )
1325 .unwrap();
1326 }
1327 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1328 assert_eq!(doc.headings.len(), 2);
1329 assert!(doc.headings.iter().all(|h| h.deadline().is_some()));
1330 }
1331
1332 #[test]
1333 fn org_safe_tags_go_on_the_heading_and_the_rest_stay_in_the_property() {
1334 let dir = tempfile::tempdir().unwrap();
1335 let layout = fresh_layout(dir.path());
1336 create(
1337 &layout,
1338 "sample",
1339 "tagged",
1340 CreateOpts {
1341 tags: Some("rust: perf ,, scaling, needs-review"),
1342 ..Default::default()
1343 },
1344 )
1345 .unwrap();
1346 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1347 let h = &doc.headings[0];
1348 assert_eq!(h.org_tags, vec!["rust", "perf", "scaling"]);
1349 assert_eq!(
1350 h.properties
1351 .get(crate::model::TAGS_PROPERTY)
1352 .map(|s| s.as_str()),
1353 Some("needs-review"),
1354 "a tag Org cannot hold keeps the property"
1355 );
1356 assert_eq!(
1358 h.tags(),
1359 vec!["needs-review", "rust", "perf", "scaling"],
1360 "{h:?}"
1361 );
1362 }
1363
1364 #[test]
1365 fn resolve_project_needs_a_name_from_somewhere() {
1366 let dir = tempfile::tempdir().unwrap();
1367 let layout = fresh_layout(dir.path());
1368 assert_eq!(
1369 resolve_project(&layout, Some("fromcli")).unwrap(),
1370 "fromcli"
1371 );
1372 assert!(
1373 resolve_project(&layout, Some(""))
1374 .unwrap_err()
1375 .to_string()
1376 .contains("empty")
1377 );
1378 }
1379
1380 #[test]
1382 fn concurrent_creates_preserve_every_heading() {
1383 use std::sync::Arc;
1384 use std::thread;
1385
1386 let dir = tempfile::tempdir().unwrap();
1387 let layout = Arc::new(fresh_layout(dir.path()));
1388 let n = 24usize;
1389 let handles: Vec<_> = (0..n)
1390 .map(|i| {
1391 let layout = Arc::clone(&layout);
1392 thread::spawn(move || {
1393 create(
1394 &layout,
1395 "sample",
1396 &format!("parallel title {i}"),
1397 CreateOpts {
1398 quiet: true,
1399 ..Default::default()
1400 },
1401 )
1402 })
1403 })
1404 .collect();
1405 let mut ids: Vec<String> = handles
1406 .into_iter()
1407 .map(|h| {
1408 h.join()
1409 .expect("thread panicked")
1410 .expect("create failed")
1411 .trim()
1412 .to_string()
1413 })
1414 .collect();
1415 ids.sort();
1416 ids.dedup();
1417 assert_eq!(ids.len(), n, "expected {n} unique ids, got {ids:?}");
1418
1419 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1420 let mut on_disk: Vec<String> = doc.headings.iter().map(|h| h.id.clone()).collect();
1421 on_disk.sort();
1422 assert_eq!(on_disk, ids);
1423 }
1424
1425 #[test]
1426 fn note_appends_to_the_logbook_and_leaves_state_alone() {
1427 let dir = tempfile::tempdir().unwrap();
1428 let layout = fresh_layout(dir.path());
1429 create(&layout, "sample", "carries a note", CreateOpts::default()).unwrap();
1430 let id = only_id(&layout, "sample");
1431
1432 let out = note(&layout, &id, "first pass done,\n \"quoted\" bit next").unwrap();
1433 assert_eq!(out, format!("{id}: noted\n"));
1434
1435 let h = issue_at(&layout, "sample", &id);
1436 assert_eq!(h.state, "TODO");
1437 assert!(h.claimed_by().is_none());
1438 let notes: Vec<&str> = h.logbook.iter().filter_map(|e| e.note.as_deref()).collect();
1439 assert_eq!(notes, vec!["first pass done, 'quoted' bit next"]);
1441 }
1442
1443 #[test]
1444 fn the_logbook_reads_newest_first_however_an_entry_arrived() {
1445 let dir = tempfile::tempdir().unwrap();
1446 let layout = fresh_layout(dir.path());
1447 create(&layout, "sample", "ordered", CreateOpts::default()).unwrap();
1448 let id = only_id(&layout, "sample");
1449
1450 note(&layout, &id, "first note").unwrap();
1451 update(&layout, &id, Some("STARTED"), None, None, None).unwrap();
1452 note(&layout, &id, "second note").unwrap();
1453
1454 let h = issue_at(&layout, "sample", &id);
1455 let summary: Vec<String> = h
1456 .logbook
1457 .iter()
1458 .map(|e| match (&e.note, &e.to_state) {
1459 (Some(note), _) => note.clone(),
1460 (_, Some(to)) => format!("state:{to}"),
1461 _ => "?".into(),
1462 })
1463 .collect();
1464 assert_eq!(
1465 summary,
1466 vec!["second note", "state:STARTED", "first note"],
1467 "{h:?}"
1468 );
1469 }
1470
1471 #[test]
1472 fn note_rejects_empty_text_and_unknown_ids() {
1473 let dir = tempfile::tempdir().unwrap();
1474 let layout = fresh_layout(dir.path());
1475 create(&layout, "sample", "target", CreateOpts::default()).unwrap();
1476 let id = only_id(&layout, "sample");
1477 assert!(note(&layout, &id, " ").is_err());
1478 assert!(note(&layout, "sample-zzz9", "text").is_err());
1479 }
1480
1481 #[test]
1482 fn fold_creates_issues_and_stamps_the_inbox_idempotently() {
1483 let dir = tempfile::tempdir().unwrap();
1484 let layout = fresh_layout(dir.path());
1485 create(&layout, "sample", "seed", CreateOpts::default()).unwrap();
1486
1487 let inbox = dir.path().join("inbox.org");
1488 fs::write(
1489 &inbox,
1490 "#+TITLE: inbox\n\n\
1491 * TODO first discovered thing\nSome body line.\nAnother line.\n\
1492 * DONE already handled elsewhere\n\
1493 * TODO second discovered thing\n",
1494 )
1495 .unwrap();
1496
1497 let out = fold(&layout, &inbox, "sample").unwrap();
1498 assert!(out.starts_with("folded 2: "), "got: {out}");
1499
1500 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1501 let titles: Vec<&str> = doc.headings.iter().map(|h| h.title.as_str()).collect();
1502 assert!(titles.contains(&"first discovered thing"));
1503 assert!(titles.contains(&"second discovered thing"));
1504 let folded = doc
1505 .headings
1506 .iter()
1507 .find(|h| h.title == "first discovered thing")
1508 .unwrap();
1509 assert!(folded.body.contains("Some body line."));
1510
1511 let stamped = fs::read_to_string(&inbox).unwrap();
1513 assert_eq!(stamped.matches("* DONE ").count(), 3);
1514 assert_eq!(stamped.matches(":VISSUE_ID: sample-").count(), 2);
1515 assert!(!stamped.contains("* TODO "));
1516
1517 let again = fold(&layout, &inbox, "sample").unwrap();
1519 assert_eq!(again, "folded 0 (nothing unstamped)\n");
1520 let doc2 = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1521 assert_eq!(doc2.headings.len(), doc.headings.len());
1522 }
1523
1524 #[test]
1525 fn refile_to_moves_across_two_layouts_and_leaves_no_shadow() {
1526 let src_dir = tempfile::tempdir().unwrap();
1527 let dst_dir = tempfile::tempdir().unwrap();
1528 let src_layout = fresh_layout(src_dir.path());
1529 let dst_layout = fresh_layout(dst_dir.path());
1530 create(&src_layout, "misc", "wrong board", CreateOpts::default()).unwrap();
1531 let id = IssueDoc::parse_file("misc", &src_layout.project_issues_path("misc"))
1532 .unwrap()
1533 .headings[0]
1534 .id
1535 .clone();
1536
1537 let out = refile_to(&src_layout, &id, &dst_layout, "surf").unwrap();
1538 assert!(out.contains("misc -> surf"), "{out}");
1539
1540 let moved = IssueDoc::parse_file("surf", &dst_layout.project_issues_path("surf")).unwrap();
1543 assert_eq!(moved.headings.len(), 1);
1544 assert_eq!(moved.headings[0].id, id);
1545 assert!(!src_layout.project_issues_path("surf").exists());
1546 let left = IssueDoc::parse_file("misc", &src_layout.project_issues_path("misc")).unwrap();
1547 assert!(left.headings.is_empty());
1548 }
1549
1550 #[test]
1551 fn reject_creates_the_successor_on_the_destination_layout() {
1552 let src_dir = tempfile::tempdir().unwrap();
1553 let dst_dir = tempfile::tempdir().unwrap();
1554 let src_layout = fresh_layout(src_dir.path());
1555 let dst_layout = fresh_layout(dst_dir.path());
1556 create(&src_layout, "misc", "old approach", CreateOpts::default()).unwrap();
1557 let src = IssueDoc::parse_file("misc", &src_layout.project_issues_path("misc"))
1558 .unwrap()
1559 .headings[0]
1560 .id
1561 .clone();
1562
1563 let taken = vec!["surf-aaaa".to_string()];
1566 let out = reject(
1567 &src_layout,
1568 &src,
1569 RejectOpts {
1570 project: Some("surf"),
1571 title: Some("new approach"),
1572 dst_layout: Some(&dst_layout),
1573 dst_extra_ids: &taken,
1574 ..Default::default()
1575 },
1576 )
1577 .unwrap();
1578
1579 assert!(!src_layout.project_issues_path("surf").exists());
1580 let made = IssueDoc::parse_file("surf", &dst_layout.project_issues_path("surf")).unwrap();
1581 assert_eq!(made.headings.len(), 1);
1582 assert_ne!(made.headings[0].id, "surf-aaaa");
1583 assert!(out.contains(&made.headings[0].id), "{out}");
1584 assert_eq!(issue_at(&src_layout, "misc", &src).state, "CANCELLED");
1585 }
1586
1587 #[test]
1588 fn reject_to_an_existing_issue_cancels_and_wires_the_pair() {
1589 let dir = tempfile::tempdir().unwrap();
1590 let layout = fresh_layout(dir.path());
1591 create(&layout, "sample", "old approach", CreateOpts::default()).unwrap();
1592 create(&layout, "sample", "new approach", CreateOpts::default()).unwrap();
1593 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1594 let src = doc.headings[0].id.clone();
1595 let dst = doc.headings[1].id.clone();
1596
1597 let out = reject(
1598 &layout,
1599 &src,
1600 RejectOpts {
1601 to: Some(&dst),
1602 ..Default::default()
1603 },
1604 )
1605 .unwrap();
1606 assert!(out.contains(&src) && out.contains(&dst), "{out}");
1607
1608 let src_h = issue_at(&layout, "sample", &src);
1609 assert_eq!(src_h.state, "CANCELLED");
1610 assert_eq!(
1611 src_h.properties.get("PIVOTED_TO").map(String::as_str),
1612 Some(dst.as_str())
1613 );
1614 let dst_h = issue_at(&layout, "sample", &dst);
1615 assert_eq!(
1616 dst_h.properties.get("DISCOVERED_FROM").map(String::as_str),
1617 Some(src.as_str())
1618 );
1619 }
1620
1621 #[test]
1622 fn reject_creates_the_destination_in_another_project() {
1623 let dir = tempfile::tempdir().unwrap();
1624 let layout = fresh_layout(dir.path());
1625 create(&layout, "sample", "old approach", CreateOpts::default()).unwrap();
1626 let src = only_id(&layout, "sample");
1627
1628 let out = reject(
1629 &layout,
1630 &src,
1631 RejectOpts {
1632 project: Some("other"),
1633 title: Some("new approach"),
1634 ..Default::default()
1635 },
1636 )
1637 .unwrap();
1638
1639 let dst_doc = IssueDoc::parse_file("other", &layout.project_issues_path("other")).unwrap();
1640 assert_eq!(dst_doc.headings.len(), 1);
1641 let dst = &dst_doc.headings[0];
1642 assert_eq!(dst.title, "new approach");
1643 assert_eq!(
1644 dst.properties.get("DISCOVERED_FROM").map(String::as_str),
1645 Some(src.as_str())
1646 );
1647 assert!(out.contains(&src) && out.contains(&dst.id), "{out}");
1648
1649 let src_h = issue_at(&layout, "sample", &src);
1650 assert_eq!(src_h.state, "CANCELLED");
1651 assert_eq!(
1652 src_h.properties.get("PIVOTED_TO").map(String::as_str),
1653 Some(dst.id.as_str())
1654 );
1655 }
1656
1657 #[test]
1658 fn reject_refuses_an_unknown_source_or_destination() {
1659 let dir = tempfile::tempdir().unwrap();
1660 let layout = fresh_layout(dir.path());
1661 create(&layout, "sample", "only", CreateOpts::default()).unwrap();
1662 let src = only_id(&layout, "sample");
1663
1664 let missing_src = reject(
1665 &layout,
1666 "sample-zzzz",
1667 RejectOpts {
1668 to: Some(&src),
1669 ..Default::default()
1670 },
1671 )
1672 .unwrap_err();
1673 assert!(
1674 matches!(missing_src, Error::IssueNotFound { .. }),
1675 "{missing_src}"
1676 );
1677
1678 let missing_dst = reject(
1679 &layout,
1680 &src,
1681 RejectOpts {
1682 to: Some("sample-zzzz"),
1683 ..Default::default()
1684 },
1685 )
1686 .unwrap_err();
1687 assert!(
1688 matches!(missing_dst, Error::IssueNotFound { .. }),
1689 "{missing_dst}"
1690 );
1691 }
1692
1693 #[test]
1694 fn reject_does_not_overwrite_a_nonempty_discovered_from() {
1695 let dir = tempfile::tempdir().unwrap();
1696 let layout = fresh_layout(dir.path());
1697 create(&layout, "sample", "origin", CreateOpts::default()).unwrap();
1698 create(&layout, "sample", "old approach", CreateOpts::default()).unwrap();
1699 create(&layout, "sample", "already sourced", CreateOpts::default()).unwrap();
1700 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1701 let origin = doc.headings[0].id.clone();
1702 let src = doc.headings[1].id.clone();
1703 let dst = doc.headings[2].id.clone();
1704
1705 let path = layout.project_issues_path("sample");
1706 let mut doc = IssueDoc::parse_file("sample", &path).unwrap();
1707 doc.headings
1708 .iter_mut()
1709 .find(|h| h.id == dst)
1710 .unwrap()
1711 .properties
1712 .insert("DISCOVERED_FROM".into(), origin.clone());
1713 doc.write().unwrap();
1714
1715 reject(
1716 &layout,
1717 &src,
1718 RejectOpts {
1719 to: Some(&dst),
1720 ..Default::default()
1721 },
1722 )
1723 .unwrap();
1724 let dst_h = issue_at(&layout, "sample", &dst);
1725 assert_eq!(
1726 dst_h.properties.get("DISCOVERED_FROM").map(String::as_str),
1727 Some(origin.as_str()),
1728 "a filled DISCOVERED_FROM stays put"
1729 );
1730 }
1731
1732 #[test]
1733 fn create_sets_discovered_from_from_the_first_known_id_link() {
1734 let dir = tempfile::tempdir().unwrap();
1735 let layout = fresh_layout(dir.path());
1736 create(&layout, "sample", "source", CreateOpts::default()).unwrap();
1737 let known = only_id(&layout, "sample");
1738 create(
1739 &layout,
1740 "sample",
1741 "fell out of it",
1742 CreateOpts {
1743 body: Some(&format!("See [[id:{known}]] for the parent finding.")),
1744 ..Default::default()
1745 },
1746 )
1747 .unwrap();
1748 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1749 let child = doc
1750 .headings
1751 .iter()
1752 .find(|h| h.title == "fell out of it")
1753 .unwrap();
1754 assert_eq!(
1755 child.properties.get("DISCOVERED_FROM").map(String::as_str),
1756 Some(known.as_str())
1757 );
1758 }
1759
1760 #[test]
1761 fn create_ignores_an_id_link_that_is_not_in_the_corpus() {
1762 let dir = tempfile::tempdir().unwrap();
1763 let layout = fresh_layout(dir.path());
1764 create(
1765 &layout,
1766 "sample",
1767 "orphan mention",
1768 CreateOpts {
1769 body: Some("See [[id:sample-zzzz]] which does not exist."),
1770 ..Default::default()
1771 },
1772 )
1773 .unwrap();
1774 let h = issue_at(&layout, "sample", &only_id(&layout, "sample"));
1775 assert!(
1776 !h.properties.contains_key("DISCOVERED_FROM"),
1777 "unknown [[id:]] must not mint DISCOVERED_FROM: {h:?}"
1778 );
1779 assert!(
1780 !h.properties.contains_key("BLOCKED_BY"),
1781 "prose must not mint BLOCKED_BY: {h:?}"
1782 );
1783 }
1784
1785 #[test]
1786 fn related_after_reject_names_the_successor_without_a_body_link() {
1787 let dir = tempfile::tempdir().unwrap();
1788 let layout = fresh_layout(dir.path());
1789 create(&layout, "sample", "old approach", CreateOpts::default()).unwrap();
1790 create(&layout, "sample", "new approach", CreateOpts::default()).unwrap();
1791 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1792 let src = doc.headings[0].id.clone();
1793 let dst = doc.headings[1].id.clone();
1794 reject(
1795 &layout,
1796 &src,
1797 RejectOpts {
1798 to: Some(&dst),
1799 ..Default::default()
1800 },
1801 )
1802 .unwrap();
1803
1804 assert!(
1805 !issue_at(&layout, "sample", &src).body.contains(&dst),
1806 "the pair is wired by PIVOTED_TO, not prose"
1807 );
1808 let from_src = crate::related::related(&layout, &src, 1, 10, "text").unwrap();
1809 assert!(from_src.contains(&dst), "{from_src}");
1810 assert!(from_src.contains("pivoted_to"), "{from_src}");
1811
1812 let from_dst = crate::related::related(&layout, &dst, 1, 10, "text").unwrap();
1813 assert!(from_dst.contains(&src), "{from_dst}");
1814 assert!(from_dst.contains("successor_of"), "{from_dst}");
1815
1816 let waiting = crate::report::backlinks(&layout, &dst).unwrap();
1817 assert!(waiting.contains(&src), "{waiting}");
1818 }
1819
1820 #[test]
1821 fn update_to_cancelled_emits_state_change_with_the_id() {
1822 let dir = tempfile::tempdir().unwrap();
1823 let layout = fresh_layout(dir.path());
1824 create(&layout, "sample", "first", CreateOpts::default()).unwrap();
1825 let id = only_id(&layout, "sample");
1826 let before = crate::events::generation(&layout);
1827 update(&layout, &id, Some("CANCELLED"), None, None, None).unwrap();
1828 let events = crate::events::since(&layout, before, 50).unwrap();
1829 assert!(
1830 events.iter().any(|e| {
1831 e.kind == "state_change"
1832 && e.id.as_deref() == Some(id.as_str())
1833 && e.detail.as_deref() == Some("TODO->CANCELLED")
1834 }),
1835 "{events:?}"
1836 );
1837 }
1838
1839 #[test]
1840 fn a_stale_done_after_reject_is_refused_and_the_source_stays_cancelled() {
1841 let dir = tempfile::tempdir().unwrap();
1842 let layout = fresh_layout(dir.path());
1843 create(&layout, "sample", "old plan", CreateOpts::default()).unwrap();
1844 create(&layout, "sample", "rewrite", CreateOpts::default()).unwrap();
1845 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1846 let src = doc.headings[0].id.clone();
1847 let dst = doc.headings[1].id.clone();
1848 reject(
1849 &layout,
1850 &src,
1851 RejectOpts {
1852 to: Some(&dst),
1853 ..Default::default()
1854 },
1855 )
1856 .unwrap();
1857
1858 let err = update_pred(
1859 &layout,
1860 &src,
1861 Some("DONE"),
1862 None,
1863 None,
1864 None,
1865 UpdatePred {
1866 if_state: Some("STARTED"),
1867 if_gen: None,
1868 },
1869 )
1870 .unwrap_err();
1871 assert!(
1872 matches!(
1873 err,
1874 Error::StaleWrite {
1875 ref actual_state,
1876 ref expected_state,
1877 ..
1878 } if actual_state == "CANCELLED" && expected_state.as_deref() == Some("STARTED")
1879 ),
1880 "{err:?}"
1881 );
1882 assert_eq!(issue_at(&layout, "sample", &src).state, "CANCELLED");
1883 }
1884
1885 #[test]
1886 fn if_gen_refuses_when_the_corpus_moved() {
1887 let dir = tempfile::tempdir().unwrap();
1888 let layout = fresh_layout(dir.path());
1889 create(&layout, "sample", "first", CreateOpts::default()).unwrap();
1890 let id = only_id(&layout, "sample");
1891 let seen = crate::events::generation(&layout);
1892 update(&layout, &id, Some("STARTED"), None, None, None).unwrap();
1893 let err = update_pred(
1894 &layout,
1895 &id,
1896 Some("DONE"),
1897 None,
1898 None,
1899 None,
1900 UpdatePred {
1901 if_state: None,
1902 if_gen: Some(seen),
1903 },
1904 )
1905 .unwrap_err();
1906 assert!(matches!(err, Error::StaleWrite { .. }), "{err:?}");
1907 assert_eq!(issue_at(&layout, "sample", &id).state, "STARTED");
1908 }
1909
1910 #[test]
1911 fn a_second_terminal_does_not_drop_the_first() {
1912 let dir = tempfile::tempdir().unwrap();
1913 let layout = fresh_layout(dir.path());
1914 create(&layout, "sample", "first", CreateOpts::default()).unwrap();
1915 let id = only_id(&layout, "sample");
1916 update(&layout, &id, Some("DONE"), None, None, None).unwrap();
1917 update(&layout, &id, Some("CANCELLED"), None, None, None).unwrap();
1918 let h = issue_at(&layout, "sample", &id);
1919 assert_eq!(h.state, "DONE", "first terminal must stay");
1920 assert_eq!(
1921 h.properties.get("SIBLING_TERMINAL").map(String::as_str),
1922 Some("CANCELLED")
1923 );
1924
1925 resolve_terminal(&layout, &id, "CANCELLED").unwrap();
1926 let h = issue_at(&layout, "sample", &id);
1927 assert_eq!(h.state, "CANCELLED");
1928 assert!(!h.properties.contains_key("SIBLING_TERMINAL"));
1929 }
1930
1931 #[test]
1932 fn check_warns_on_reject_prose_done_and_a_mention_without_an_edge() {
1933 let dir = tempfile::tempdir().unwrap();
1934 let layout = fresh_layout(dir.path());
1935 create(&layout, "sample", "shipped", CreateOpts::default()).unwrap();
1936 create(&layout, "sample", "other", CreateOpts::default()).unwrap();
1937 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1938 let shipped = doc.headings[0].id.clone();
1939 let other = doc.headings[1].id.clone();
1940 update(&layout, &shipped, Some("DONE"), None, None, None).unwrap();
1941 append_body(&layout, &shipped, "rejected in the append, bounced").unwrap();
1942 append_body(&layout, &other, &format!("see [[id:{shipped}]]")).unwrap();
1943
1944 let report = crate::report::check(&layout).unwrap();
1945 assert!(
1946 report.text.contains(&shipped)
1947 && report.text.contains("DONE but the body reads as a reject"),
1948 "{}",
1949 report.text
1950 );
1951 assert!(
1952 report.text.contains(&other)
1953 && report.text.contains("no DISCOVERED_FROM or PIVOTED_TO"),
1954 "{}",
1955 report.text
1956 );
1957 assert!(report.warnings >= 2, "{}", report.text);
1958 }
1959}