1use anyhow::{Context, anyhow};
4
5use crate::error::Result;
6use chrono::NaiveDate;
7use std::collections::BTreeMap;
8use std::fmt::Write as _;
9
10use crate::config::{Layout, VissueConfig};
11use crate::error::Error;
12use crate::graph::DependencyGraph;
13use crate::model::{IssueHeading, LogEntry, TODO_KEYWORDS, today_inactive_bracket};
14use crate::store::{
15 IssueDoc, collect_org_ids, detect_project_from_ctx, find_by_id, generate_id, load_all,
16 resolve_existing_project_case, with_issues_lock, with_issues_locks,
17};
18
19pub fn resolve_project(layout: &Layout, explicit: Option<&str>) -> Result<String> {
29 if let Some(p) = explicit {
30 if p.is_empty() {
31 return Err(anyhow!("--project given but empty").into());
32 }
33 return resolve_existing_project_case(layout, p);
34 }
35 let cwd = std::env::current_dir()?;
36 let detected = detect_project_from_ctx(&cwd).ok_or_else(|| {
37 anyhow!(
38 "no --project given and no .project-ctx.toml found walking up from {}",
39 cwd.display()
40 )
41 })?;
42 resolve_existing_project_case(layout, &detected)
43}
44
45#[derive(Debug, Default, Clone, Copy)]
47pub struct CreateOpts<'a> {
48 pub priority: Option<char>,
50 pub issue_type: Option<&'a str>,
52 pub deadline: Option<&'a str>,
54 pub scheduled: Option<&'a str>,
56 pub tags: Option<&'a str>,
58 pub parent: Option<&'a str>,
60 pub quiet: bool,
62 pub body: Option<&'a str>,
64 pub extra_ids: &'a [String],
67}
68
69pub fn create(layout: &Layout, project: &str, title: &str, opts: CreateOpts<'_>) -> Result<String> {
81 let project = resolve_existing_project_case(layout, project)?;
82 let cfg = VissueConfig::load(layout)?;
83 let path = layout.project_issues_path(&project);
84 let (spec, named) = match IssueDoc::parse_file(&project, &path) {
85 Ok(doc) => (doc.priority_spec(), doc.priorities_are_named()),
86 Err(_) => (crate::org::PrioritySpec::default(), false),
87 };
88 let house_new = !path.exists();
89 let priority = opts.priority.unwrap_or(if named || house_new {
90 spec.default
91 } else {
92 cfg.issues.default_priority
93 });
94 if !spec.contains(priority) {
95 return Err(anyhow!(
96 "invalid priority {priority:?}; file allows [#{}]..[#{}]",
97 spec.highest,
98 spec.lowest
99 )
100 .into());
101 }
102
103 let known_ids = if opts.parent.is_some() || opts.body.is_some() {
105 collect_org_ids(layout)?
106 } else {
107 std::collections::HashSet::new()
108 };
109 if let Some(p) = opts.parent
110 && !known_ids.contains(p)
111 {
112 return Err(anyhow!("--parent {p} does not refer to any known id").into());
113 }
114
115 with_issues_lock(&path, || {
116 let mut doc = IssueDoc::parse_file(&project, &path)?;
117 let mut taken = doc.known_ids();
118 taken.extend(opts.extra_ids.iter().cloned());
119 let id = generate_id(&project, &taken, cfg.issues.id_length)?;
120
121 let mut props = BTreeMap::new();
122 props.insert("ID".into(), id.clone());
123 props.insert("CREATED".into(), today_inactive_bracket());
124 if crate::props::get(&props, crate::props::DISCOVERED_FROM).is_none()
125 && let Some(body) = opts.body
126 && let Some(origin) = first_existing_id_link(body, &known_ids)
127 {
128 crate::props::insert(&mut props, crate::props::DISCOVERED_FROM, origin);
129 }
130 let mut org_tags: Vec<String> = Vec::new();
131 if let Some(t) = opts.issue_type {
132 crate::props::insert(&mut props, crate::props::TYPE, t.into());
133 if t.chars().all(crate::model::is_org_tag_char)
136 && !t.is_empty()
137 && !org_tags.iter().any(|seen| seen == t)
138 {
139 org_tags.push(t.to_string());
140 }
141 }
142 if let Some(d) = opts.deadline {
143 validate_org_date(d)?;
144 props.insert("DEADLINE".into(), d.into());
145 }
146 if let Some(s) = opts.scheduled {
147 validate_org_date(s)?;
148 props.insert("SCHEDULED".into(), s.into());
149 }
150 if let Some(tags) = opts.tags {
154 let mut property_tags: Vec<String> = Vec::new();
155 for tag in tags.split([',', ':']).map(str::trim) {
156 if tag.is_empty() {
157 continue;
158 }
159 if tag.chars().all(crate::model::is_org_tag_char) {
160 if !org_tags.iter().any(|seen| seen == tag) {
161 org_tags.push(tag.to_string());
162 }
163 } else if !property_tags.iter().any(|seen| seen == tag) {
164 property_tags.push(tag.to_string());
165 }
166 }
167 if !property_tags.is_empty() {
168 props.insert(crate::model::TAGS_PROPERTY.into(), property_tags.join(","));
169 }
170 }
171 if let Some(p) = opts.parent {
172 crate::props::insert(&mut props, crate::props::PARENT, p.into());
173 }
174
175 doc.headings.push(IssueHeading {
176 id: id.clone(),
177 title: title.to_string(),
178 state: "TODO".into(),
179 priority,
180 properties: props,
181 org_tags,
182 statistics: None,
183 property_order: Vec::new(),
184 extra_drawers: Vec::new(),
185 body: match opts.body {
186 Some(b) if !b.trim().is_empty() => format!("{}\n", b.trim_end()),
187 _ => String::new(),
188 },
189 logbook: Vec::new(),
190 line_start: 0,
191 line_end: 0,
192 });
193 doc.write()?;
194
195 if opts.quiet {
196 Ok(format!("{id}\n"))
197 } else {
198 Ok(format!(
199 "{id} TODO [#{priority}] {title}\nfile: {}\n",
200 path.display()
201 ))
202 }
203 })
204}
205
206pub(crate) fn validate_org_date(s: &str) -> Result<()> {
207 let inner = s
208 .trim_start_matches(['<', '['])
209 .trim_end_matches(['>', ']']);
210 let token = inner.split_whitespace().next().unwrap_or("");
211 NaiveDate::parse_from_str(token, "%Y-%m-%d").with_context(|| {
212 format!("expected org date like <YYYY-MM-DD> or [YYYY-MM-DD], got {s:?}")
213 })?;
214 Ok(())
215}
216
217pub fn update(
225 layout: &Layout,
226 id: &str,
227 new_state: Option<&str>,
228 new_priority: Option<char>,
229 block_add: Option<&str>,
230 block_clear: Option<&str>,
231) -> Result<UpdateOutcome> {
232 let identity = crate::config::identity(layout);
233 update_as(
234 layout,
235 id,
236 new_state,
237 new_priority,
238 block_add,
239 block_clear,
240 &identity,
241 )
242}
243
244#[derive(Debug, Default, Clone, Copy)]
249pub struct UpdatePred<'a> {
250 pub if_state: Option<&'a str>,
252 pub if_gen: Option<u64>,
254}
255
256pub fn update_pred(
262 layout: &Layout,
263 id: &str,
264 new_state: Option<&str>,
265 new_priority: Option<char>,
266 block_add: Option<&str>,
267 block_clear: Option<&str>,
268 pred: UpdatePred<'_>,
269) -> Result<UpdateOutcome> {
270 let identity = crate::config::identity(layout);
271 update_as_pred(
272 layout,
273 id,
274 new_state,
275 new_priority,
276 block_add,
277 block_clear,
278 &identity,
279 pred,
280 )
281}
282
283pub fn update_as(
290 layout: &Layout,
291 id: &str,
292 new_state: Option<&str>,
293 new_priority: Option<char>,
294 block_add: Option<&str>,
295 block_clear: Option<&str>,
296 identity: &str,
297) -> Result<UpdateOutcome> {
298 update_as_pred(
299 layout,
300 id,
301 new_state,
302 new_priority,
303 block_add,
304 block_clear,
305 identity,
306 UpdatePred::default(),
307 )
308}
309
310#[allow(clippy::too_many_arguments)]
316pub fn update_as_pred(
317 layout: &Layout,
318 id: &str,
319 new_state: Option<&str>,
320 new_priority: Option<char>,
321 block_add: Option<&str>,
322 block_clear: Option<&str>,
323 identity: &str,
324 pred: UpdatePred<'_>,
325) -> Result<UpdateOutcome> {
326 let (_h0, path, project) =
327 find_by_id(layout, id)?.ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
328
329 let (transition, changed) = with_issues_lock(&path, || {
330 let graph = if block_add.is_some() {
333 Some(DependencyGraph::from_issues(&load_all(layout)?)?)
334 } else {
335 None
336 };
337 let mut doc = IssueDoc::parse_file(&project, &path)?;
338 let spec = doc.priority_spec();
339 let h = doc
340 .headings
341 .iter_mut()
342 .find(|x| x.id == id)
343 .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
344
345 let original = h.state.clone();
346 let mut changed = Vec::new();
347
348 if pred.if_state.is_some() || pred.if_gen.is_some() {
349 let seen = crate::events::generation(layout);
350 if let Some(want) = pred.if_state {
351 if !TODO_KEYWORDS.contains(&want) {
352 return Err(
353 anyhow!("invalid --if-state {want:?}; allowed: {TODO_KEYWORDS:?}").into(),
354 );
355 }
356 if h.state != want {
357 return Err(Error::StaleWrite {
358 id: id.to_string(),
359 expected_state: Some(want.to_string()),
360 actual_state: h.state.clone(),
361 expected_gen: pred.if_gen,
362 actual_gen: Some(seen),
363 });
364 }
365 }
366 if let Some(want_gen) = pred.if_gen
367 && seen != want_gen
368 {
369 return Err(Error::StaleWrite {
370 id: id.to_string(),
371 expected_state: pred.if_state.map(str::to_string),
372 actual_state: h.state.clone(),
373 expected_gen: Some(want_gen),
374 actual_gen: Some(seen),
375 });
376 }
377 }
378
379 if let Some(s) = new_state {
380 if !TODO_KEYWORDS.contains(&s) {
381 return Err(anyhow!("invalid state {s:?}; allowed: {TODO_KEYWORDS:?}").into());
382 }
383 if h.state != s {
384 if is_terminal(&h.state) && is_terminal(s) {
385 record_sibling_terminal(h, s);
386 changed.push(format!("sibling terminal {s} (held {})", h.state));
387 } else {
388 let from = h.state.clone();
389 h.record_state_change(s);
390 changed.push(format!("state {from} -> {s}"));
391 for note in settle_claim(h, &from, s, identity) {
392 changed.push(note);
393 }
394 }
395 }
396 }
397
398 if let Some(p) = new_priority {
399 if !spec.contains(p) {
400 return Err(anyhow!(
401 "invalid priority {p:?}; file allows [#{}]..[#{}]",
402 spec.highest,
403 spec.lowest
404 )
405 .into());
406 }
407 if h.priority != p {
408 h.priority = p;
409 changed.push(format!("priority -> [#{p}]"));
410 }
411 }
412
413 if let Some(blk) = block_add {
414 let mut current = h.blocked_by();
415 if !current.iter().any(|x| x == blk) {
416 if let Some(graph) = &graph {
417 graph.accepts_edge(blk, id)?;
418 }
419 current.push(blk.to_string());
420 crate::props::insert(
421 &mut h.properties,
422 crate::props::BLOCKED_BY,
423 current.join(" "),
424 );
425 if h.state == "TODO" || h.state == "STARTED" {
426 let from = h.state.clone();
427 h.record_state_change("BLOCKED");
428 changed.push(format!("state {from} -> BLOCKED (auto on block)"));
429 }
430 changed.push(format!("blocked_by += {blk}"));
431 }
432 }
433
434 if let Some(blk) = block_clear {
435 let mut current = h.blocked_by();
436 let before = current.len();
437 current.retain(|x| x != blk);
438 if current.len() < before {
439 if current.is_empty() {
440 crate::props::remove(&mut h.properties, crate::props::BLOCKED_BY);
441 if h.state == "BLOCKED" {
442 let from = h.state.clone();
443 h.record_state_change("TODO");
444 changed.push("state BLOCKED -> TODO (auto on unblock)".to_string());
445 for note in settle_claim(h, &from, "TODO", identity) {
446 changed.push(note);
447 }
448 }
449 } else {
450 crate::props::insert(
451 &mut h.properties,
452 crate::props::BLOCKED_BY,
453 current.join(" "),
454 );
455 }
456 changed.push(format!("blocked_by -= {blk}"));
457 }
458 }
459
460 if changed.is_empty() {
461 return Ok((None, Vec::new()));
462 }
463
464 let final_state = h.state.clone();
465 doc.write()?;
466 let transition = (original != final_state).then_some((original, final_state));
467 Ok((transition, changed))
468 })?;
469
470 if changed.is_empty() {
471 return Ok(UpdateOutcome {
472 report: format!("{id}: no change\n"),
473 hints: Vec::new(),
474 });
475 }
476
477 if let Some((from, to)) = &transition {
478 let _ = crate::events::emit_state_change(layout, &project, id, from, to);
479 }
480
481 let mut hints = Vec::new();
482 if matches!(
483 transition.as_ref().map(|(_, to)| to.as_str()),
484 Some("DONE") | Some("CANCELLED")
485 ) {
486 for (other_project, other) in load_all(layout)? {
487 if !other.blocked_by().iter().any(|b| b == id) {
488 continue;
489 }
490 if other.state == "DONE" || other.state == "CANCELLED" {
491 continue;
492 }
493 hints.push(format!(
494 "{} (in {}) lists this as a blocker; clear with `vissue update {} --unblock {}`",
495 other.id, other_project, other.id, id
496 ));
497 }
498 }
499 Ok(UpdateOutcome {
500 report: format!("{id}: {}\n", changed.join(", ")),
501 hints,
502 })
503}
504
505fn keeps_claim(state: &str) -> bool {
508 matches!(state, "STARTED" | "BLOCKED")
509}
510
511fn is_terminal(state: &str) -> bool {
512 matches!(state, "DONE" | "CANCELLED")
513}
514
515fn record_sibling_terminal(h: &mut IssueHeading, attempted: &str) {
516 crate::props::insert(
517 &mut h.properties,
518 crate::props::SIBLING_TERMINAL,
519 attempted.to_string(),
520 );
521}
522
523pub fn resolve_terminal(layout: &Layout, id: &str, state: &str) -> Result<String> {
530 if !is_terminal(state) {
531 return Err(anyhow!("resolve state must be DONE or CANCELLED, got {state:?}").into());
532 }
533 let identity = crate::config::identity(layout);
534 let (_h0, path, project) =
535 find_by_id(layout, id)?.ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
536 let from = with_issues_lock(&path, || {
537 let mut doc = IssueDoc::parse_file(&project, &path)?;
538 let h = doc
539 .headings
540 .iter_mut()
541 .find(|x| x.id == id)
542 .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
543 let from = h.state.clone();
544 if from != state {
545 h.record_state_change(state);
546 settle_claim(h, &from, state, &identity);
547 }
548 crate::props::remove(&mut h.properties, crate::props::SIBLING_TERMINAL);
549 doc.write()?;
550 Ok(from)
551 })?;
552 if from != state {
553 let _ = crate::events::emit_state_change(layout, &project, id, &from, state);
554 }
555 Ok(format!("resolved {id} -> {state}\n"))
556}
557
558fn settle_claim(h: &mut IssueHeading, from: &str, to: &str, identity: &str) -> Vec<String> {
564 let mut notes = Vec::new();
565 if to == "STARTED" && h.claimed_by().is_none() {
566 h.set_claim(identity);
567 notes.push(format!("claimed by {identity}"));
568 } else if keeps_claim(from)
569 && !keeps_claim(to)
570 && let Some((who, _when)) = h.release_claim()
571 {
572 notes.push(format!("claim released ({who})"));
573 }
574 notes
575}
576
577pub fn claim(layout: &Layout, id: &str, force: bool) -> Result<String> {
588 let identity = crate::config::identity(layout);
589 claim_as(layout, id, force, &identity)
590}
591
592pub fn claim_as(layout: &Layout, id: &str, force: bool, identity: &str) -> Result<String> {
600 let (_h0, path, project) =
601 find_by_id(layout, id)?.ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
602
603 let report = with_issues_lock(&path, || {
604 let mut doc = IssueDoc::parse_file(&project, &path)?;
605 let h = doc
606 .headings
607 .iter_mut()
608 .find(|x| x.id == id)
609 .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
610
611 if h.state == "DONE" || h.state == "CANCELLED" {
612 return Err(Error::InvalidState {
613 id: id.to_string(),
614 state: h.state.clone(),
615 });
616 }
617 if let Some(holder) = h.claimed_by() {
618 if holder != identity && !force {
619 return Err(Error::ClaimConflict {
620 id: id.to_string(),
621 holder: holder.to_string(),
622 claimed_at: h.claimed_at().map(str::to_string),
623 });
624 }
625 if holder != identity {
626 let previous = holder.to_string();
627 let from = h.state.clone();
628 h.release_claim();
629 h.set_claim(identity);
630 h.record_state_change("STARTED");
631 doc.write()?;
632 if from != "STARTED" {
633 let _ =
634 crate::events::emit_state_change(layout, &project, id, &from, "STARTED");
635 }
636 return Ok(format!("claimed {id} (taken over from {previous})\n"));
637 }
638 }
639
640 let was = h.state.clone();
641 h.record_state_change("STARTED");
642 if h.claimed_by().is_none() {
643 h.set_claim(identity);
644 }
645 doc.write()?;
646 if was != "STARTED" {
647 let _ = crate::events::emit_state_change(layout, &project, id, &was, "STARTED");
648 }
649 if was == "STARTED" {
650 Ok(format!("claimed {id} by {identity}\n"))
651 } else {
652 Ok(format!("claimed {id} by {identity} ({was} -> STARTED)\n"))
653 }
654 })?;
655 Ok(report)
656}
657
658#[derive(Debug, Clone)]
660pub struct UpdateOutcome {
661 pub report: String,
663 pub hints: Vec<String>,
665}
666
667pub fn note(layout: &Layout, id: &str, text: &str) -> Result<String> {
676 let text = text
679 .split_whitespace()
680 .collect::<Vec<_>>()
681 .join(" ")
682 .replace('"', "'");
683 if text.is_empty() {
684 return Err(anyhow!("note text is empty").into());
685 }
686 let (_h0, path, project) =
687 find_by_id(layout, id)?.ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
688 with_issues_lock(&path, || {
689 let mut doc = IssueDoc::parse_file(&project, &path)?;
690 let h = doc
691 .headings
692 .iter_mut()
693 .find(|x| x.id == id)
694 .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
695 h.logbook.insert(
698 0,
699 LogEntry {
700 timestamp: LogEntry::now(),
701 from_state: None,
702 to_state: None,
703 note: Some(text.clone()),
704 raw: None,
705 },
706 );
707 doc.write()?;
708 Ok(format!("{id}: noted\n"))
709 })
710}
711
712pub fn append_body(layout: &Layout, id: &str, text: &str) -> Result<String> {
727 append_body_as(layout, id, text, &crate::config::identity(layout))
728}
729
730pub fn append_body_as(layout: &Layout, id: &str, text: &str, identity: &str) -> Result<String> {
737 let text = text.trim_end();
738 if text.trim().is_empty() {
739 return Err(anyhow!("append text is empty").into());
740 }
741 let (_h0, path, project) =
742 find_by_id(layout, id)?.ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
743 with_issues_lock(&path, || {
744 let mut doc = IssueDoc::parse_file(&project, &path)?;
745 let h = doc
746 .headings
747 .iter_mut()
748 .find(|x| x.id == id)
749 .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
750 let stamp = format!("{} {identity}", today_inactive_bracket());
751 if !h.body.trim().is_empty() {
752 h.body = h.body.trim_end().to_string();
753 h.body.push_str("\n\n");
754 } else {
755 h.body.clear();
756 }
757 h.body.push_str(&stamp);
758 h.body.push('\n');
759 h.body.push_str(text);
760 h.body.push('\n');
761 doc.write()?;
762 let lines = text.lines().count();
763 Ok(format!("{id}: appended {lines} line(s)\n"))
764 })
765}
766
767pub fn fold(layout: &Layout, inbox: &std::path::Path, project: &str) -> Result<String> {
781 let project = resolve_existing_project_case(layout, project)?;
782 let text = std::fs::read_to_string(inbox)
783 .with_context(|| format!("read inbox {}", inbox.display()))?;
784 let lines: Vec<String> = text.lines().map(str::to_string).collect();
785
786 struct Entry {
787 line: usize,
788 title: String,
789 body: String,
790 stamped: bool,
791 }
792 let mut entries: Vec<Entry> = Vec::new();
793 let mut i = 0;
794 let mut nest = crate::org::OrgScan::new();
795 while i < lines.len() {
796 if nest.observe(&lines[i]) {
797 i += 1;
798 continue;
799 }
800 if let Some(title) = lines[i].strip_prefix("* TODO ") {
801 let start = i + 1;
802 let mut end_nest = crate::org::OrgScan::new();
803 let end = {
804 let mut j = start;
805 while j < lines.len() {
806 if !end_nest.observe(&lines[j]) && lines[j].starts_with("* ") {
807 break;
808 }
809 j += 1;
810 }
811 j
812 };
813 let stamped = lines[start..end]
814 .iter()
815 .any(|l| l.trim_start().starts_with(":VISSUE_ID:"));
816 let body = lines[start..end].join("\n").trim().to_string();
817 entries.push(Entry {
818 line: i,
819 title: title.trim().to_string(),
820 body,
821 stamped,
822 });
823 i = end;
824 } else {
825 i += 1;
826 }
827 }
828
829 let mut out = lines.clone();
832 let mut created: Vec<String> = Vec::new();
833 let mut failure = None;
834 for e in entries.iter().rev() {
835 if e.stamped {
836 continue;
837 }
838 let printed = create(
839 layout,
840 &project,
841 &e.title,
842 CreateOpts {
843 quiet: true,
844 body: if e.body.is_empty() {
845 None
846 } else {
847 Some(&e.body)
848 },
849 ..CreateOpts::default()
850 },
851 );
852 let id = match printed {
853 Ok(printed) => printed.trim().to_string(),
854 Err(e) => {
855 failure = Some(e);
859 break;
860 }
861 };
862 out[e.line] = format!("* DONE {}", e.title);
863 out.insert(e.line + 1, format!(":VISSUE_ID: {id}"));
864 created.push(id);
865 }
866 created.reverse();
867
868 if !created.is_empty() {
869 let mut rendered = out.join("\n");
870 if text.ends_with('\n') {
871 rendered.push('\n');
872 }
873 std::fs::write(inbox, rendered)
874 .with_context(|| format!("write inbox {}", inbox.display()))?;
875 }
876 if let Some(error) = failure {
877 return Err(crate::error::Error::Other(
878 anyhow::Error::from(error).context(format!(
879 "folded {} before failing: {}",
880 created.len(),
881 created.join(" ")
882 )),
883 ));
884 }
885 if created.is_empty() {
886 return Ok("folded 0 (nothing unstamped)\n".into());
887 }
888 Ok(format!("folded {}: {}\n", created.len(), created.join(" ")))
889}
890
891pub fn refile(layout: &Layout, id: &str, to_project: &str) -> Result<String> {
899 refile_to(layout, id, layout, to_project)
900}
901
902pub fn refile_to(
911 layout: &Layout,
912 id: &str,
913 dst_layout: &Layout,
914 to_project: &str,
915) -> Result<String> {
916 let to_project = resolve_existing_project_case(dst_layout, to_project)?;
917 let target_path = dst_layout.project_issues_path(&to_project);
918 let (_heading, src_path, src_project) =
919 find_by_id(layout, id)?.ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
920 if src_path == target_path {
921 return Ok(format!("{id} already in {to_project}; nothing to do\n"));
922 }
923 with_issues_locks(&[&src_path, &target_path], || {
924 let mut src_doc = IssueDoc::parse_file(&src_project, &src_path)?;
925 let heading = src_doc
926 .remove(id)
927 .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
928
929 let mut tgt_doc = IssueDoc::parse_file(&to_project, &target_path)?;
935 tgt_doc.upsert(heading);
936 tgt_doc.write()?;
937 src_doc.write()?;
938 Ok(())
939 })?;
940 Ok(format!("{id}: {src_project} -> {to_project}\n"))
941}
942
943#[derive(Debug, Default, Clone, Copy)]
945pub struct RejectOpts<'a> {
946 pub to: Option<&'a str>,
948 pub project: Option<&'a str>,
950 pub title: Option<&'a str>,
952 pub reason: Option<&'a str>,
954 pub dst_layout: Option<&'a Layout>,
956 pub dst_extra_ids: &'a [String],
959}
960
961pub fn reject(layout: &Layout, src: &str, opts: RejectOpts<'_>) -> Result<String> {
974 let identity = crate::config::identity(layout);
975 let (src0, src_path, src_project) =
976 find_by_id(layout, src)?.ok_or_else(|| Error::IssueNotFound {
977 id: src.to_string(),
978 })?;
979
980 let dst_layout = opts.dst_layout.unwrap_or(layout);
981 let existing_dst = if let Some(to) = opts.to {
982 if to == src {
983 return Err(anyhow!("reject destination cannot be the source {src}").into());
984 }
985 Some(
986 find_by_id(dst_layout, to)?
987 .ok_or_else(|| Error::IssueNotFound { id: to.to_string() })?,
988 )
989 } else {
990 None
991 };
992
993 let creating = existing_dst.is_none();
994 if creating && opts.project.is_none() {
995 return Err(anyhow!("reject needs --to DST or --project to create a successor").into());
996 }
997
998 let dst_project = if let Some((_, _, ref project)) = existing_dst {
999 project.clone()
1000 } else {
1001 resolve_existing_project_case(dst_layout, opts.project.unwrap_or(&src_project))?
1002 };
1003 let dst_path = dst_layout.project_issues_path(&dst_project);
1004 let dst_title = opts.title.unwrap_or(src0.title.as_str());
1005 let cfg = VissueConfig::load(layout)?;
1006
1007 let (dst_id, old_state, new_state) = with_issues_locks(&[&src_path, &dst_path], || {
1008 if src_path == dst_path {
1009 let mut doc = IssueDoc::parse_file(&src_project, &src_path)?;
1010 let dst_id = if creating {
1011 push_successor(
1012 &mut doc,
1013 &dst_project,
1014 dst_title,
1015 src,
1016 &cfg,
1017 opts.dst_extra_ids,
1018 )?
1019 } else {
1020 let to = reject_to(opts)?;
1021 set_discovered_from_if_empty(&mut doc, to, src)?;
1022 to.to_string()
1023 };
1024 let (old_state, new_state) =
1025 cancel_and_pivot(&mut doc, src, &dst_id, opts.reason, &identity)?;
1026 doc.write()?;
1027 Ok((dst_id, old_state, new_state))
1028 } else {
1029 let mut src_doc = IssueDoc::parse_file(&src_project, &src_path)?;
1030 let mut dst_doc = IssueDoc::parse_file(&dst_project, &dst_path)?;
1031 let dst_id = if creating {
1032 push_successor(
1033 &mut dst_doc,
1034 &dst_project,
1035 dst_title,
1036 src,
1037 &cfg,
1038 opts.dst_extra_ids,
1039 )?
1040 } else {
1041 let to = reject_to(opts)?;
1042 set_discovered_from_if_empty(&mut dst_doc, to, src)?;
1043 to.to_string()
1044 };
1045 let (old_state, new_state) =
1046 cancel_and_pivot(&mut src_doc, src, &dst_id, opts.reason, &identity)?;
1047 dst_doc.write()?;
1048 src_doc.write()?;
1049 Ok((dst_id, old_state, new_state))
1050 }
1051 })?;
1052
1053 if old_state != new_state {
1054 let _ = crate::events::emit_state_change(layout, &src_project, src, &old_state, &new_state);
1055 }
1056 Ok(format!("rejected {src} -> {dst_id}\n"))
1057}
1058
1059fn reject_to(opts: RejectOpts<'_>) -> Result<&str> {
1060 opts.to
1061 .ok_or_else(|| anyhow!("reject destination missing after --to was required").into())
1062}
1063
1064fn push_successor(
1065 doc: &mut IssueDoc,
1066 project: &str,
1067 title: &str,
1068 src: &str,
1069 cfg: &VissueConfig,
1070 extra_ids: &[String],
1071) -> Result<String> {
1072 let mut taken = doc.known_ids();
1073 taken.extend(extra_ids.iter().cloned());
1074 let id = generate_id(project, &taken, cfg.issues.id_length)?;
1075 let mut props = BTreeMap::new();
1076 props.insert("ID".into(), id.clone());
1077 props.insert("CREATED".into(), today_inactive_bracket());
1078 crate::props::insert(&mut props, crate::props::DISCOVERED_FROM, src.to_string());
1079 doc.headings.push(IssueHeading {
1080 id: id.clone(),
1081 title: title.to_string(),
1082 state: "TODO".into(),
1083 priority: doc.default_create_priority(cfg.issues.default_priority),
1084 properties: props,
1085 org_tags: Vec::new(),
1086 statistics: None,
1087 property_order: Vec::new(),
1088 extra_drawers: Vec::new(),
1089 body: String::new(),
1090 logbook: Vec::new(),
1091 line_start: 0,
1092 line_end: 0,
1093 });
1094 Ok(id)
1095}
1096
1097fn set_discovered_from_if_empty(doc: &mut IssueDoc, id: &str, src: &str) -> Result<()> {
1098 let h = doc
1099 .headings
1100 .iter_mut()
1101 .find(|h| h.id == id)
1102 .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
1103 let empty = crate::props::get(&h.properties, crate::props::DISCOVERED_FROM)
1104 .is_none_or(|s| s.trim().is_empty());
1105 if empty {
1106 crate::props::insert(
1107 &mut h.properties,
1108 crate::props::DISCOVERED_FROM,
1109 src.to_string(),
1110 );
1111 }
1112 Ok(())
1113}
1114
1115fn cancel_and_pivot(
1116 doc: &mut IssueDoc,
1117 src: &str,
1118 dst: &str,
1119 reason: Option<&str>,
1120 identity: &str,
1121) -> Result<(String, String)> {
1122 let h = doc
1123 .headings
1124 .iter_mut()
1125 .find(|h| h.id == src)
1126 .ok_or_else(|| Error::IssueNotFound {
1127 id: src.to_string(),
1128 })?;
1129 let old_state = h.state.clone();
1130 if is_terminal(&old_state) && old_state != "CANCELLED" {
1131 record_sibling_terminal(h, "CANCELLED");
1132 } else if old_state != "CANCELLED" {
1133 h.record_state_change("CANCELLED");
1134 settle_claim(h, &old_state, "CANCELLED", identity);
1135 }
1136 crate::props::insert(&mut h.properties, crate::props::PIVOTED_TO, dst.to_string());
1137 if let Some(reason) = reason {
1138 append_reason(h, reason, identity);
1139 }
1140 Ok((old_state, h.state.clone()))
1141}
1142
1143fn append_reason(h: &mut IssueHeading, text: &str, identity: &str) {
1144 let text = text.trim_end();
1145 if text.trim().is_empty() {
1146 return;
1147 }
1148 let stamp = format!("{} {identity}", today_inactive_bracket());
1149 if !h.body.trim().is_empty() {
1150 h.body = h.body.trim_end().to_string();
1151 h.body.push_str("\n\n");
1152 } else {
1153 h.body.clear();
1154 }
1155 h.body.push_str(&stamp);
1156 h.body.push('\n');
1157 h.body.push_str(text);
1158 h.body.push('\n');
1159}
1160
1161fn first_existing_id_link(body: &str, known: &std::collections::HashSet<String>) -> Option<String> {
1163 let mut rest = body;
1164 while let Some(start) = rest.find("[[") {
1165 let after_start = &rest[start + 2..];
1166 let end = after_start.find("]]")?;
1167 let raw = &after_start[..end];
1168 let target = raw.split_once("][").map_or(raw, |(target, _)| target);
1169 let target = target.trim();
1170 if let Some(id) = target.strip_prefix("id:") {
1171 let id = id.trim();
1172 if known.contains(id) {
1173 return Some(id.to_string());
1174 }
1175 }
1176 rest = &after_start[end + 2..];
1177 }
1178 None
1179}
1180
1181pub fn normalize(layout: &Layout, project: Option<&str>, dry_run: bool) -> Result<String> {
1192 let projects = match project {
1193 Some(name) => vec![resolve_existing_project_case(layout, name)?],
1194 None => crate::store::list_projects(layout)?,
1195 };
1196 let mut out = String::new();
1197 let mut files = 0usize;
1198 let mut headings = 0usize;
1199 let mut changed = 0usize;
1200 for project in projects {
1201 let path = layout.project_issues_path(&project);
1202 if !path.exists() {
1203 continue;
1204 }
1205 files += 1;
1206 let before =
1207 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
1208 let report = with_issues_lock(&path, || {
1209 let mut doc = IssueDoc::parse_file(&project, &path)?;
1210 let mut moved = 0usize;
1211 for h in &mut doc.headings {
1212 moved += crate::props::settle(&mut h.org_tags, &mut h.properties);
1213 }
1214 let after = doc.render_string();
1215 if after != before {
1216 if !dry_run {
1217 doc.write()?;
1218 }
1219 Ok(Some((moved, after.len())))
1220 } else {
1221 Ok(None)
1222 }
1223 })?;
1224 headings += IssueDoc::parse(&project, path.clone(), &before)
1225 .map(|d| d.headings.len())
1226 .unwrap_or(0);
1227 if let Some((moved, _)) = report {
1228 changed += 1;
1229 let verb = if dry_run { "would rewrite" } else { "rewrote" };
1230 writeln!(out, "{verb} {project} ({moved} key move(s))")?;
1231 }
1232 }
1233 let mode = if dry_run { "dry-run" } else { "wrote" };
1234 writeln!(
1235 out,
1236 "normalize {mode}: {changed}/{files} file(s) changed, {headings} heading(s) scanned"
1237 )?;
1238 Ok(out)
1239}
1240
1241#[cfg(test)]
1242mod tests {
1243 use super::*;
1244 use crate::config::DEFAULT_PREFIX;
1245 use std::fs;
1246 use std::path::Path;
1247
1248 fn fresh_layout(dir: &Path) -> Layout {
1249 fs::create_dir_all(dir.join(DEFAULT_PREFIX)).unwrap();
1250 Layout::new(dir, DEFAULT_PREFIX)
1251 }
1252
1253 fn issue_at(layout: &Layout, project: &str, id: &str) -> IssueHeading {
1254 IssueDoc::parse_file(project, &layout.project_issues_path(project))
1255 .unwrap()
1256 .headings
1257 .into_iter()
1258 .find(|h| h.id == id)
1259 .expect("issue not found")
1260 }
1261
1262 fn only_id(layout: &Layout, project: &str) -> String {
1263 IssueDoc::parse_file(project, &layout.project_issues_path(project))
1264 .unwrap()
1265 .headings[0]
1266 .id
1267 .clone()
1268 }
1269
1270 #[test]
1271 fn create_rejects_a_parent_that_does_not_exist() {
1272 let dir = tempfile::tempdir().unwrap();
1273 let layout = fresh_layout(dir.path());
1274 let err = create(
1275 &layout,
1276 "sample",
1277 "child without parent",
1278 CreateOpts {
1279 parent: Some("sample-zzz9"),
1280 ..Default::default()
1281 },
1282 )
1283 .unwrap_err();
1284 assert!(err.to_string().contains("does not refer to any known id"));
1285 }
1286
1287 #[test]
1288 fn create_accepts_a_parent_defined_in_a_design_document() {
1289 let dir = tempfile::tempdir().unwrap();
1290 let layout = fresh_layout(dir.path());
1291 let parent_id = "sample-spec-20260615";
1292 let project_dir = layout.projects_dir().join("sample");
1293 fs::create_dir_all(&project_dir).unwrap();
1294 fs::write(
1295 project_dir.join("design.org"),
1296 format!("#+TITLE: sample design\n\n* Design\n:PROPERTIES:\n:ID: {parent_id}\n:END:\n"),
1297 )
1298 .unwrap();
1299
1300 create(
1301 &layout,
1302 "sample",
1303 "child under design",
1304 CreateOpts {
1305 parent: Some(parent_id),
1306 ..Default::default()
1307 },
1308 )
1309 .unwrap();
1310 assert!(only_id(&layout, "sample").starts_with("sample-"));
1311 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1312 assert_eq!(doc.headings[0].parent(), Some(parent_id));
1313 }
1314
1315 #[test]
1316 fn a_state_update_writes_a_logbook_entry() {
1317 let dir = tempfile::tempdir().unwrap();
1318 let layout = fresh_layout(dir.path());
1319 create(&layout, "sample", "first", CreateOpts::default()).unwrap();
1320 let id = only_id(&layout, "sample");
1321 update(&layout, &id, Some("STARTED"), None, None, None).unwrap();
1322 let h = issue_at(&layout, "sample", &id);
1323 assert_eq!(h.state, "STARTED");
1324 assert_eq!(h.logbook[0].from_state.as_deref(), Some("TODO"));
1325 assert_eq!(h.logbook[0].to_state.as_deref(), Some("STARTED"));
1326 }
1327
1328 #[test]
1329 fn blocking_and_unblocking_drive_the_state() {
1330 let dir = tempfile::tempdir().unwrap();
1331 let layout = fresh_layout(dir.path());
1332 create(&layout, "sample", "first", CreateOpts::default()).unwrap();
1333 create(&layout, "sample", "blocker", CreateOpts::default()).unwrap();
1334 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1335 let first = doc.headings[0].id.clone();
1336 let blocker = doc.headings[1].id.clone();
1337
1338 update(&layout, &first, None, None, Some(&blocker), None).unwrap();
1339 let h = issue_at(&layout, "sample", &first);
1340 assert_eq!(h.state, "BLOCKED");
1341 assert!(h.blocked_by().contains(&blocker));
1342
1343 update(&layout, &first, None, None, None, Some(&blocker)).unwrap();
1344 let h = issue_at(&layout, "sample", &first);
1345 assert_eq!(h.state, "TODO");
1346 assert!(h.blocked_by().is_empty());
1347 }
1348
1349 #[test]
1350 fn auto_unblock_to_todo_releases_the_claim() {
1351 let dir = tempfile::tempdir().unwrap();
1352 let layout = fresh_layout(dir.path());
1353 create(&layout, "sample", "first", CreateOpts::default()).unwrap();
1354 create(&layout, "sample", "blocker", CreateOpts::default()).unwrap();
1355 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1356 let first = doc.headings[0].id.clone();
1357 let blocker = doc.headings[1].id.clone();
1358
1359 crate::agent::claim(&layout, &first, false).unwrap();
1360 update(&layout, &first, None, None, Some(&blocker), None).unwrap();
1361 assert!(issue_at(&layout, "sample", &first).claimed_by().is_some());
1362
1363 update(&layout, &first, None, None, None, Some(&blocker)).unwrap();
1364 let h = issue_at(&layout, "sample", &first);
1365 assert_eq!(h.state, "TODO");
1366 assert!(h.claimed_by().is_none(), "claim stuck on TODO: {h:?}");
1367 }
1368
1369 #[test]
1370 fn blocker_cycle_is_rejected_before_writing() {
1371 let dir = tempfile::tempdir().unwrap();
1372 let layout = fresh_layout(dir.path());
1373 create(&layout, "sample", "first", CreateOpts::default()).unwrap();
1374 create(&layout, "sample", "second", CreateOpts::default()).unwrap();
1375 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1376 let first = doc.headings[0].id.clone();
1377 let second = doc.headings[1].id.clone();
1378
1379 update(&layout, &first, None, None, Some(&second), None).unwrap();
1380 let err = update(&layout, &second, None, None, Some(&first), None).unwrap_err();
1381 assert!(err.to_string().contains("blocker cycle"), "{err}");
1382 assert!(issue_at(&layout, "sample", &second).blocked_by().is_empty());
1383 }
1384
1385 #[test]
1386 fn closing_a_blocker_reports_the_issues_still_pointing_at_it() {
1387 let dir = tempfile::tempdir().unwrap();
1388 let layout = fresh_layout(dir.path());
1389 create(&layout, "sample", "first", CreateOpts::default()).unwrap();
1390 create(&layout, "sample", "blocker", CreateOpts::default()).unwrap();
1391 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1392 let first = doc.headings[0].id.clone();
1393 let blocker = doc.headings[1].id.clone();
1394 update(&layout, &first, None, None, Some(&blocker), None).unwrap();
1395
1396 let outcome = update(&layout, &blocker, Some("DONE"), None, None, None).unwrap();
1397 assert_eq!(outcome.hints.len(), 1, "{:?}", outcome.hints);
1398 assert!(outcome.hints[0].contains(&first), "{:?}", outcome.hints);
1399 }
1400
1401 #[test]
1402 fn refile_moves_the_heading_between_projects() {
1403 let dir = tempfile::tempdir().unwrap();
1404 let layout = fresh_layout(dir.path());
1405 create(&layout, "source", "the issue", CreateOpts::default()).unwrap();
1406 let id = only_id(&layout, "source");
1407 refile(&layout, &id, "target").unwrap();
1408
1409 let src = IssueDoc::parse_file("source", &layout.project_issues_path("source")).unwrap();
1410 let tgt = IssueDoc::parse_file("target", &layout.project_issues_path("target")).unwrap();
1411 assert!(src.headings.is_empty());
1412 assert_eq!(tgt.headings[0].id, id);
1413 }
1414
1415 #[test]
1416 fn deadlines_must_parse_as_org_dates() {
1417 let dir = tempfile::tempdir().unwrap();
1418 let layout = fresh_layout(dir.path());
1419 let err = create(
1420 &layout,
1421 "sample",
1422 "bad date",
1423 CreateOpts {
1424 deadline: Some("not-a-date"),
1425 ..Default::default()
1426 },
1427 )
1428 .unwrap_err();
1429 assert!(err.to_string().contains("expected org date"));
1430
1431 for (i, d) in ["<2026-05-15 Fri>", "[2026-05-15]"].iter().enumerate() {
1432 create(
1433 &layout,
1434 "sample",
1435 &format!("issue {i}"),
1436 CreateOpts {
1437 deadline: Some(d),
1438 ..Default::default()
1439 },
1440 )
1441 .unwrap();
1442 }
1443 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1444 assert_eq!(doc.headings.len(), 2);
1445 assert!(doc.headings.iter().all(|h| h.deadline().is_some()));
1446 }
1447
1448 #[test]
1449 fn org_safe_tags_go_on_the_heading_and_the_rest_stay_in_the_property() {
1450 let dir = tempfile::tempdir().unwrap();
1451 let layout = fresh_layout(dir.path());
1452 create(
1453 &layout,
1454 "sample",
1455 "tagged",
1456 CreateOpts {
1457 tags: Some("rust: perf ,, scaling, needs-review"),
1458 ..Default::default()
1459 },
1460 )
1461 .unwrap();
1462 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1463 let h = &doc.headings[0];
1464 assert_eq!(h.org_tags, vec!["rust", "perf", "scaling"]);
1465 assert_eq!(
1466 h.properties
1467 .get(crate::model::TAGS_PROPERTY)
1468 .map(|s| s.as_str()),
1469 Some("needs-review"),
1470 "a tag Org cannot hold keeps the property"
1471 );
1472 assert_eq!(
1474 h.tags(),
1475 vec!["needs-review", "rust", "perf", "scaling"],
1476 "{h:?}"
1477 );
1478 }
1479
1480 #[test]
1481 fn create_puts_a_legal_type_on_the_heading() {
1482 let dir = tempfile::tempdir().unwrap();
1483 let layout = fresh_layout(dir.path());
1484 create(
1485 &layout,
1486 "sample",
1487 "a bug",
1488 CreateOpts {
1489 issue_type: Some("bug"),
1490 ..Default::default()
1491 },
1492 )
1493 .unwrap();
1494 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1495 let h = &doc.headings[0];
1496 assert_eq!(
1497 crate::props::get(&h.properties, crate::props::TYPE),
1498 Some("bug")
1499 );
1500 assert_eq!(h.org_tags, vec!["bug"]);
1501 let written = std::fs::read_to_string(layout.project_issues_path("sample")).unwrap();
1502 assert!(written.contains("#+CATEGORY: sample"), "{written}");
1503 assert!(written.contains(":bug:"), "{written}");
1504 }
1505
1506 #[test]
1507 fn resolve_project_needs_a_name_from_somewhere() {
1508 let dir = tempfile::tempdir().unwrap();
1509 let layout = fresh_layout(dir.path());
1510 assert_eq!(
1511 resolve_project(&layout, Some("fromcli")).unwrap(),
1512 "fromcli"
1513 );
1514 assert!(
1515 resolve_project(&layout, Some(""))
1516 .unwrap_err()
1517 .to_string()
1518 .contains("empty")
1519 );
1520 }
1521
1522 #[test]
1524 fn concurrent_creates_preserve_every_heading() {
1525 use std::sync::Arc;
1526 use std::thread;
1527
1528 let dir = tempfile::tempdir().unwrap();
1529 let layout = Arc::new(fresh_layout(dir.path()));
1530 let n = 24usize;
1531 let handles: Vec<_> = (0..n)
1532 .map(|i| {
1533 let layout = Arc::clone(&layout);
1534 thread::spawn(move || {
1535 create(
1536 &layout,
1537 "sample",
1538 &format!("parallel title {i}"),
1539 CreateOpts {
1540 quiet: true,
1541 ..Default::default()
1542 },
1543 )
1544 })
1545 })
1546 .collect();
1547 let mut ids: Vec<String> = handles
1548 .into_iter()
1549 .map(|h| {
1550 h.join()
1551 .expect("thread panicked")
1552 .expect("create failed")
1553 .trim()
1554 .to_string()
1555 })
1556 .collect();
1557 ids.sort();
1558 ids.dedup();
1559 assert_eq!(ids.len(), n, "expected {n} unique ids, got {ids:?}");
1560
1561 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1562 let mut on_disk: Vec<String> = doc.headings.iter().map(|h| h.id.clone()).collect();
1563 on_disk.sort();
1564 assert_eq!(on_disk, ids);
1565 }
1566
1567 #[test]
1568 fn note_appends_to_the_logbook_and_leaves_state_alone() {
1569 let dir = tempfile::tempdir().unwrap();
1570 let layout = fresh_layout(dir.path());
1571 create(&layout, "sample", "carries a note", CreateOpts::default()).unwrap();
1572 let id = only_id(&layout, "sample");
1573
1574 let out = note(&layout, &id, "first pass done,\n \"quoted\" bit next").unwrap();
1575 assert_eq!(out, format!("{id}: noted\n"));
1576
1577 let h = issue_at(&layout, "sample", &id);
1578 assert_eq!(h.state, "TODO");
1579 assert!(h.claimed_by().is_none());
1580 let notes: Vec<&str> = h.logbook.iter().filter_map(|e| e.note.as_deref()).collect();
1581 assert_eq!(notes, vec!["first pass done, 'quoted' bit next"]);
1583 }
1584
1585 #[test]
1586 fn the_logbook_reads_newest_first_however_an_entry_arrived() {
1587 let dir = tempfile::tempdir().unwrap();
1588 let layout = fresh_layout(dir.path());
1589 create(&layout, "sample", "ordered", CreateOpts::default()).unwrap();
1590 let id = only_id(&layout, "sample");
1591
1592 note(&layout, &id, "first note").unwrap();
1593 update(&layout, &id, Some("STARTED"), None, None, None).unwrap();
1594 note(&layout, &id, "second note").unwrap();
1595
1596 let h = issue_at(&layout, "sample", &id);
1597 let summary: Vec<String> = h
1598 .logbook
1599 .iter()
1600 .map(|e| match (&e.note, &e.to_state) {
1601 (Some(note), _) => note.clone(),
1602 (_, Some(to)) => format!("state:{to}"),
1603 _ => "?".into(),
1604 })
1605 .collect();
1606 assert_eq!(
1607 summary,
1608 vec!["second note", "state:STARTED", "first note"],
1609 "{h:?}"
1610 );
1611 }
1612
1613 #[test]
1614 fn note_rejects_empty_text_and_unknown_ids() {
1615 let dir = tempfile::tempdir().unwrap();
1616 let layout = fresh_layout(dir.path());
1617 create(&layout, "sample", "target", CreateOpts::default()).unwrap();
1618 let id = only_id(&layout, "sample");
1619 assert!(note(&layout, &id, " ").is_err());
1620 assert!(note(&layout, "sample-zzz9", "text").is_err());
1621 }
1622
1623 #[test]
1624 fn fold_creates_issues_and_stamps_the_inbox_idempotently() {
1625 let dir = tempfile::tempdir().unwrap();
1626 let layout = fresh_layout(dir.path());
1627 create(&layout, "sample", "seed", CreateOpts::default()).unwrap();
1628
1629 let inbox = dir.path().join("inbox.org");
1630 fs::write(
1631 &inbox,
1632 "#+TITLE: inbox\n\n\
1633 * TODO first discovered thing\nSome body line.\nAnother line.\n\
1634 * DONE already handled elsewhere\n\
1635 * TODO second discovered thing\n",
1636 )
1637 .unwrap();
1638
1639 let out = fold(&layout, &inbox, "sample").unwrap();
1640 assert!(out.starts_with("folded 2: "), "got: {out}");
1641
1642 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1643 let titles: Vec<&str> = doc.headings.iter().map(|h| h.title.as_str()).collect();
1644 assert!(titles.contains(&"first discovered thing"));
1645 assert!(titles.contains(&"second discovered thing"));
1646 let folded = doc
1647 .headings
1648 .iter()
1649 .find(|h| h.title == "first discovered thing")
1650 .unwrap();
1651 assert!(folded.body.contains("Some body line."));
1652
1653 let stamped = fs::read_to_string(&inbox).unwrap();
1655 assert_eq!(stamped.matches("* DONE ").count(), 3);
1656 assert_eq!(stamped.matches(":VISSUE_ID: sample-").count(), 2);
1657 assert!(!stamped.contains("* TODO "));
1658
1659 let again = fold(&layout, &inbox, "sample").unwrap();
1661 assert_eq!(again, "folded 0 (nothing unstamped)\n");
1662 let doc2 = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1663 assert_eq!(doc2.headings.len(), doc.headings.len());
1664 }
1665
1666 #[test]
1667 fn refile_to_moves_across_two_layouts_and_leaves_no_shadow() {
1668 let src_dir = tempfile::tempdir().unwrap();
1669 let dst_dir = tempfile::tempdir().unwrap();
1670 let src_layout = fresh_layout(src_dir.path());
1671 let dst_layout = fresh_layout(dst_dir.path());
1672 create(&src_layout, "misc", "wrong board", CreateOpts::default()).unwrap();
1673 let id = IssueDoc::parse_file("misc", &src_layout.project_issues_path("misc"))
1674 .unwrap()
1675 .headings[0]
1676 .id
1677 .clone();
1678
1679 let out = refile_to(&src_layout, &id, &dst_layout, "surf").unwrap();
1680 assert!(out.contains("misc -> surf"), "{out}");
1681
1682 let moved = IssueDoc::parse_file("surf", &dst_layout.project_issues_path("surf")).unwrap();
1685 assert_eq!(moved.headings.len(), 1);
1686 assert_eq!(moved.headings[0].id, id);
1687 assert!(!src_layout.project_issues_path("surf").exists());
1688 let left = IssueDoc::parse_file("misc", &src_layout.project_issues_path("misc")).unwrap();
1689 assert!(left.headings.is_empty());
1690 }
1691
1692 #[test]
1693 fn reject_creates_the_successor_on_the_destination_layout() {
1694 let src_dir = tempfile::tempdir().unwrap();
1695 let dst_dir = tempfile::tempdir().unwrap();
1696 let src_layout = fresh_layout(src_dir.path());
1697 let dst_layout = fresh_layout(dst_dir.path());
1698 create(&src_layout, "misc", "old approach", CreateOpts::default()).unwrap();
1699 let src = IssueDoc::parse_file("misc", &src_layout.project_issues_path("misc"))
1700 .unwrap()
1701 .headings[0]
1702 .id
1703 .clone();
1704
1705 let taken = vec!["surf-aaaa".to_string()];
1708 let out = reject(
1709 &src_layout,
1710 &src,
1711 RejectOpts {
1712 project: Some("surf"),
1713 title: Some("new approach"),
1714 dst_layout: Some(&dst_layout),
1715 dst_extra_ids: &taken,
1716 ..Default::default()
1717 },
1718 )
1719 .unwrap();
1720
1721 assert!(!src_layout.project_issues_path("surf").exists());
1722 let made = IssueDoc::parse_file("surf", &dst_layout.project_issues_path("surf")).unwrap();
1723 assert_eq!(made.headings.len(), 1);
1724 assert_ne!(made.headings[0].id, "surf-aaaa");
1725 assert!(out.contains(&made.headings[0].id), "{out}");
1726 assert_eq!(issue_at(&src_layout, "misc", &src).state, "CANCELLED");
1727 }
1728
1729 #[test]
1730 fn reject_to_an_existing_issue_cancels_and_wires_the_pair() {
1731 let dir = tempfile::tempdir().unwrap();
1732 let layout = fresh_layout(dir.path());
1733 create(&layout, "sample", "old approach", CreateOpts::default()).unwrap();
1734 create(&layout, "sample", "new approach", CreateOpts::default()).unwrap();
1735 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1736 let src = doc.headings[0].id.clone();
1737 let dst = doc.headings[1].id.clone();
1738
1739 let out = reject(
1740 &layout,
1741 &src,
1742 RejectOpts {
1743 to: Some(&dst),
1744 ..Default::default()
1745 },
1746 )
1747 .unwrap();
1748 assert!(out.contains(&src) && out.contains(&dst), "{out}");
1749
1750 let src_h = issue_at(&layout, "sample", &src);
1751 assert_eq!(src_h.state, "CANCELLED");
1752 assert_eq!(
1753 src_h.properties.get("PIVOTED_TO").map(String::as_str),
1754 Some(dst.as_str())
1755 );
1756 let dst_h = issue_at(&layout, "sample", &dst);
1757 assert_eq!(
1758 dst_h.properties.get("DISCOVERED_FROM").map(String::as_str),
1759 Some(src.as_str())
1760 );
1761 }
1762
1763 #[test]
1764 fn reject_creates_the_destination_in_another_project() {
1765 let dir = tempfile::tempdir().unwrap();
1766 let layout = fresh_layout(dir.path());
1767 create(&layout, "sample", "old approach", CreateOpts::default()).unwrap();
1768 let src = only_id(&layout, "sample");
1769
1770 let out = reject(
1771 &layout,
1772 &src,
1773 RejectOpts {
1774 project: Some("other"),
1775 title: Some("new approach"),
1776 ..Default::default()
1777 },
1778 )
1779 .unwrap();
1780
1781 let dst_doc = IssueDoc::parse_file("other", &layout.project_issues_path("other")).unwrap();
1782 assert_eq!(dst_doc.headings.len(), 1);
1783 let dst = &dst_doc.headings[0];
1784 assert_eq!(dst.title, "new approach");
1785 assert_eq!(
1786 dst.properties.get("DISCOVERED_FROM").map(String::as_str),
1787 Some(src.as_str())
1788 );
1789 assert!(out.contains(&src) && out.contains(&dst.id), "{out}");
1790
1791 let src_h = issue_at(&layout, "sample", &src);
1792 assert_eq!(src_h.state, "CANCELLED");
1793 assert_eq!(
1794 src_h.properties.get("PIVOTED_TO").map(String::as_str),
1795 Some(dst.id.as_str())
1796 );
1797 }
1798
1799 #[test]
1800 fn reject_refuses_an_unknown_source_or_destination() {
1801 let dir = tempfile::tempdir().unwrap();
1802 let layout = fresh_layout(dir.path());
1803 create(&layout, "sample", "only", CreateOpts::default()).unwrap();
1804 let src = only_id(&layout, "sample");
1805
1806 let missing_src = reject(
1807 &layout,
1808 "sample-zzzz",
1809 RejectOpts {
1810 to: Some(&src),
1811 ..Default::default()
1812 },
1813 )
1814 .unwrap_err();
1815 assert!(
1816 matches!(missing_src, Error::IssueNotFound { .. }),
1817 "{missing_src}"
1818 );
1819
1820 let missing_dst = reject(
1821 &layout,
1822 &src,
1823 RejectOpts {
1824 to: Some("sample-zzzz"),
1825 ..Default::default()
1826 },
1827 )
1828 .unwrap_err();
1829 assert!(
1830 matches!(missing_dst, Error::IssueNotFound { .. }),
1831 "{missing_dst}"
1832 );
1833 }
1834
1835 #[test]
1836 fn reject_does_not_overwrite_a_nonempty_discovered_from() {
1837 let dir = tempfile::tempdir().unwrap();
1838 let layout = fresh_layout(dir.path());
1839 create(&layout, "sample", "origin", CreateOpts::default()).unwrap();
1840 create(&layout, "sample", "old approach", CreateOpts::default()).unwrap();
1841 create(&layout, "sample", "already sourced", CreateOpts::default()).unwrap();
1842 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1843 let origin = doc.headings[0].id.clone();
1844 let src = doc.headings[1].id.clone();
1845 let dst = doc.headings[2].id.clone();
1846
1847 let path = layout.project_issues_path("sample");
1848 let mut doc = IssueDoc::parse_file("sample", &path).unwrap();
1849 doc.headings
1850 .iter_mut()
1851 .find(|h| h.id == dst)
1852 .unwrap()
1853 .properties
1854 .insert("DISCOVERED_FROM".into(), origin.clone());
1855 doc.write().unwrap();
1856
1857 reject(
1858 &layout,
1859 &src,
1860 RejectOpts {
1861 to: Some(&dst),
1862 ..Default::default()
1863 },
1864 )
1865 .unwrap();
1866 let dst_h = issue_at(&layout, "sample", &dst);
1867 assert_eq!(
1868 dst_h.properties.get("DISCOVERED_FROM").map(String::as_str),
1869 Some(origin.as_str()),
1870 "a filled DISCOVERED_FROM stays put"
1871 );
1872 }
1873
1874 #[test]
1875 fn create_sets_discovered_from_from_the_first_known_id_link() {
1876 let dir = tempfile::tempdir().unwrap();
1877 let layout = fresh_layout(dir.path());
1878 create(&layout, "sample", "source", CreateOpts::default()).unwrap();
1879 let known = only_id(&layout, "sample");
1880 create(
1881 &layout,
1882 "sample",
1883 "fell out of it",
1884 CreateOpts {
1885 body: Some(&format!("See [[id:{known}]] for the parent finding.")),
1886 ..Default::default()
1887 },
1888 )
1889 .unwrap();
1890 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1891 let child = doc
1892 .headings
1893 .iter()
1894 .find(|h| h.title == "fell out of it")
1895 .unwrap();
1896 assert_eq!(
1897 child.properties.get("DISCOVERED_FROM").map(String::as_str),
1898 Some(known.as_str())
1899 );
1900 }
1901
1902 #[test]
1903 fn create_ignores_an_id_link_that_is_not_in_the_corpus() {
1904 let dir = tempfile::tempdir().unwrap();
1905 let layout = fresh_layout(dir.path());
1906 create(
1907 &layout,
1908 "sample",
1909 "orphan mention",
1910 CreateOpts {
1911 body: Some("See [[id:sample-zzzz]] which does not exist."),
1912 ..Default::default()
1913 },
1914 )
1915 .unwrap();
1916 let h = issue_at(&layout, "sample", &only_id(&layout, "sample"));
1917 assert!(
1918 !h.properties.contains_key("DISCOVERED_FROM"),
1919 "unknown [[id:]] must not mint DISCOVERED_FROM: {h:?}"
1920 );
1921 assert!(
1922 !h.properties.contains_key("BLOCKED_BY"),
1923 "prose must not mint BLOCKED_BY: {h:?}"
1924 );
1925 }
1926
1927 #[test]
1928 fn related_after_reject_names_the_successor_without_a_body_link() {
1929 let dir = tempfile::tempdir().unwrap();
1930 let layout = fresh_layout(dir.path());
1931 create(&layout, "sample", "old approach", CreateOpts::default()).unwrap();
1932 create(&layout, "sample", "new approach", CreateOpts::default()).unwrap();
1933 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1934 let src = doc.headings[0].id.clone();
1935 let dst = doc.headings[1].id.clone();
1936 reject(
1937 &layout,
1938 &src,
1939 RejectOpts {
1940 to: Some(&dst),
1941 ..Default::default()
1942 },
1943 )
1944 .unwrap();
1945
1946 assert!(
1947 !issue_at(&layout, "sample", &src).body.contains(&dst),
1948 "the pair is wired by PIVOTED_TO, not prose"
1949 );
1950 let from_src = crate::related::related(&layout, &src, 1, 10, "text").unwrap();
1951 assert!(from_src.contains(&dst), "{from_src}");
1952 assert!(from_src.contains("pivoted_to"), "{from_src}");
1953
1954 let from_dst = crate::related::related(&layout, &dst, 1, 10, "text").unwrap();
1955 assert!(from_dst.contains(&src), "{from_dst}");
1956 assert!(from_dst.contains("successor_of"), "{from_dst}");
1957
1958 let waiting = crate::report::backlinks(&layout, &dst).unwrap();
1959 assert!(waiting.contains(&src), "{waiting}");
1960 }
1961
1962 #[test]
1963 fn update_to_cancelled_emits_state_change_with_the_id() {
1964 let dir = tempfile::tempdir().unwrap();
1965 let layout = fresh_layout(dir.path());
1966 create(&layout, "sample", "first", CreateOpts::default()).unwrap();
1967 let id = only_id(&layout, "sample");
1968 let before = crate::events::generation(&layout);
1969 update(&layout, &id, Some("CANCELLED"), None, None, None).unwrap();
1970 let events = crate::events::since(&layout, before, 50).unwrap();
1971 assert!(
1972 events.iter().any(|e| {
1973 e.kind == "state_change"
1974 && e.id.as_deref() == Some(id.as_str())
1975 && e.detail.as_deref() == Some("TODO->CANCELLED")
1976 }),
1977 "{events:?}"
1978 );
1979 }
1980
1981 #[test]
1982 fn a_stale_done_after_reject_is_refused_and_the_source_stays_cancelled() {
1983 let dir = tempfile::tempdir().unwrap();
1984 let layout = fresh_layout(dir.path());
1985 create(&layout, "sample", "old plan", CreateOpts::default()).unwrap();
1986 create(&layout, "sample", "rewrite", CreateOpts::default()).unwrap();
1987 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1988 let src = doc.headings[0].id.clone();
1989 let dst = doc.headings[1].id.clone();
1990 reject(
1991 &layout,
1992 &src,
1993 RejectOpts {
1994 to: Some(&dst),
1995 ..Default::default()
1996 },
1997 )
1998 .unwrap();
1999
2000 let err = update_pred(
2001 &layout,
2002 &src,
2003 Some("DONE"),
2004 None,
2005 None,
2006 None,
2007 UpdatePred {
2008 if_state: Some("STARTED"),
2009 if_gen: None,
2010 },
2011 )
2012 .unwrap_err();
2013 assert!(
2014 matches!(
2015 err,
2016 Error::StaleWrite {
2017 ref actual_state,
2018 ref expected_state,
2019 ..
2020 } if actual_state == "CANCELLED" && expected_state.as_deref() == Some("STARTED")
2021 ),
2022 "{err:?}"
2023 );
2024 assert_eq!(issue_at(&layout, "sample", &src).state, "CANCELLED");
2025 }
2026
2027 #[test]
2028 fn if_gen_refuses_when_the_corpus_moved() {
2029 let dir = tempfile::tempdir().unwrap();
2030 let layout = fresh_layout(dir.path());
2031 create(&layout, "sample", "first", CreateOpts::default()).unwrap();
2032 let id = only_id(&layout, "sample");
2033 let seen = crate::events::generation(&layout);
2034 update(&layout, &id, Some("STARTED"), None, None, None).unwrap();
2035 let err = update_pred(
2036 &layout,
2037 &id,
2038 Some("DONE"),
2039 None,
2040 None,
2041 None,
2042 UpdatePred {
2043 if_state: None,
2044 if_gen: Some(seen),
2045 },
2046 )
2047 .unwrap_err();
2048 assert!(matches!(err, Error::StaleWrite { .. }), "{err:?}");
2049 assert_eq!(issue_at(&layout, "sample", &id).state, "STARTED");
2050 }
2051
2052 #[test]
2053 fn a_second_terminal_does_not_drop_the_first() {
2054 let dir = tempfile::tempdir().unwrap();
2055 let layout = fresh_layout(dir.path());
2056 create(&layout, "sample", "first", CreateOpts::default()).unwrap();
2057 let id = only_id(&layout, "sample");
2058 update(&layout, &id, Some("DONE"), None, None, None).unwrap();
2059 update(&layout, &id, Some("CANCELLED"), None, None, None).unwrap();
2060 let h = issue_at(&layout, "sample", &id);
2061 assert_eq!(h.state, "DONE", "first terminal must stay");
2062 assert_eq!(
2063 crate::props::get(&h.properties, crate::props::SIBLING_TERMINAL),
2064 Some("CANCELLED")
2065 );
2066
2067 resolve_terminal(&layout, &id, "CANCELLED").unwrap();
2068 let h = issue_at(&layout, "sample", &id);
2069 assert_eq!(h.state, "CANCELLED");
2070 assert!(crate::props::get(&h.properties, crate::props::SIBLING_TERMINAL).is_none());
2071 }
2072
2073 #[test]
2074 fn check_warns_on_reject_prose_done_and_a_mention_without_an_edge() {
2075 let dir = tempfile::tempdir().unwrap();
2076 let layout = fresh_layout(dir.path());
2077 create(&layout, "sample", "shipped", CreateOpts::default()).unwrap();
2078 create(&layout, "sample", "other", CreateOpts::default()).unwrap();
2079 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
2080 let shipped = doc.headings[0].id.clone();
2081 let other = doc.headings[1].id.clone();
2082 update(&layout, &shipped, Some("DONE"), None, None, None).unwrap();
2083 append_body(&layout, &shipped, "rejected in the append, bounced").unwrap();
2084 append_body(&layout, &other, &format!("see [[id:{shipped}]]")).unwrap();
2085
2086 let report = crate::report::check(&layout).unwrap();
2087 assert!(
2088 report.text.contains(&shipped)
2089 && report.text.contains("DONE but the body reads as a reject"),
2090 "{}",
2091 report.text
2092 );
2093 assert!(
2094 report.text.contains(&other)
2095 && report.text.contains("no DISCOVERED_FROM or PIVOTED_TO"),
2096 "{}",
2097 report.text
2098 );
2099 assert!(report.warnings >= 2, "{}", report.text);
2100 }
2101
2102 #[test]
2103 fn check_names_a_file_missing_category_and_a_type_not_on_the_heading() {
2104 let dir = tempfile::tempdir().unwrap();
2105 let layout = fresh_layout(dir.path());
2106 let path = layout.project_issues_path("sample");
2107 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2108 std::fs::write(
2109 &path,
2110 "#+TITLE: sample issues\n#+TODO: TODO STARTED BLOCKED | DONE CANCELLED\n\n* TODO [#A] Untagged type\n:PROPERTIES:\n:ID: sample-aaaa\n:TYPE: bug\n:END:\n",
2111 )
2112 .unwrap();
2113 let report = crate::report::check(&layout).unwrap();
2114 assert!(
2115 report.text.contains("sample: preamble has no #+CATEGORY:"),
2116 "{}",
2117 report.text
2118 );
2119 assert!(
2120 report
2121 .text
2122 .contains("have :TYPE: that is a legal Org tag but is not on the heading"),
2123 "{}",
2124 report.text
2125 );
2126 assert!(
2127 report
2128 .text
2129 .contains("preamble has no #+VISSUE: protocol stamp"),
2130 "{}",
2131 report.text
2132 );
2133 assert!(
2134 report.text.contains("preamble has no #+PRIORITIES:"),
2135 "{}",
2136 report.text
2137 );
2138 }
2139
2140 #[test]
2141 fn check_errors_on_a_newer_protocol_stamp() {
2142 let dir = tempfile::tempdir().unwrap();
2143 let layout = fresh_layout(dir.path());
2144 let path = layout.project_issues_path("sample");
2145 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2146 std::fs::write(
2147 &path,
2148 "#+TITLE: sample issues\n#+VISSUE: 99\n#+CATEGORY: sample\n#+FILETAGS: :issues:sample:noexport:\n#+TAGS: docs\n#+TODO: TODO | DONE\n\n* TODO [#A] Future\n:PROPERTIES:\n:ID: sample-aaaa\n:END:\n",
2149 )
2150 .unwrap();
2151 let report = crate::report::check(&layout).unwrap();
2152 assert!(report.errors >= 1, "{}", report.text);
2153 assert!(
2154 report
2155 .text
2156 .contains("#+VISSUE: 99 is newer than this vissue"),
2157 "{}",
2158 report.text
2159 );
2160 }
2161
2162 #[test]
2163 fn normalize_rewrites_legacy_keys_and_keeps_edna() {
2164 let dir = tempfile::tempdir().unwrap();
2165 let layout = fresh_layout(dir.path());
2166 let path = layout.project_issues_path("sample");
2167 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2168 std::fs::write(
2169 &path,
2170 "#+TITLE: sample issues\n#+TODO: TODO STARTED BLOCKED | DONE CANCELLED\n\n* TODO [#A] Legacy\n:PROPERTIES:\n:ID: sample-aaaa\n:TYPE: bug\n:PARENT: sample-root\n:BLOCKEDBY: sample-bbbb\n:END:\n\n* TODO [#A] Edna condition\n:PROPERTIES:\n:ID: sample-cccc\n:BLOCKER: prev-sibling\n:END:\n",
2171 )
2172 .unwrap();
2173 let dry = normalize(&layout, Some("sample"), true).unwrap();
2174 assert!(dry.contains("would rewrite"), "{dry}");
2175 let on_disk = std::fs::read_to_string(&path).unwrap();
2176 assert!(on_disk.contains(":TYPE:"), "{on_disk}");
2177 let wrote = normalize(&layout, Some("sample"), false).unwrap();
2178 assert!(wrote.contains("rewrote"), "{wrote}");
2179 let after = std::fs::read_to_string(&path).unwrap();
2180 assert!(after.contains("#+CATEGORY: sample"), "{after}");
2181 assert!(after.contains("#+PRIORITIES: A C C"), "{after}");
2182 assert!(after.contains(":TYPE: bug"), "{after}");
2183 assert!(after.contains(":PARENT:"), "{after}");
2184 assert!(after.contains(":BLOCKED_BY:"), "{after}");
2185 assert!(
2186 !after.contains("ids(sample-bbbb)"),
2187 "normalize must not mint edna ids(): {after}"
2188 );
2189 assert!(after.contains("prev-sibling"), "{after}");
2190 }
2191}