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}
64
65pub fn create(layout: &Layout, project: &str, title: &str, opts: CreateOpts<'_>) -> Result<String> {
73 let project = resolve_existing_project_case(layout, project)?;
74 let cfg = VissueConfig::load(layout)?;
75 let priority = opts.priority.unwrap_or(cfg.issues.default_priority);
76 if !"ABC".contains(priority) {
77 return Err(anyhow!("invalid priority {priority:?}; allowed: A B C").into());
78 }
79 let path = layout.project_issues_path(&project);
80
81 if let Some(p) = opts.parent
83 && !collect_org_ids(layout)?.contains(p)
84 {
85 return Err(anyhow!("--parent {p} does not refer to any known id").into());
86 }
87
88 with_issues_lock(&path, || {
89 let mut doc = IssueDoc::parse_file(&project, &path)?;
90 let id = generate_id(&project, &doc.known_ids(), cfg.issues.id_length)?;
91
92 let mut props = BTreeMap::new();
93 props.insert("ID".into(), id.clone());
94 props.insert("CREATED".into(), today_inactive_bracket());
95 if let Some(t) = opts.issue_type {
96 props.insert("TYPE".into(), t.into());
97 }
98 if let Some(d) = opts.deadline {
99 validate_org_date(d)?;
100 props.insert("DEADLINE".into(), d.into());
101 }
102 if let Some(s) = opts.scheduled {
103 validate_org_date(s)?;
104 props.insert("SCHEDULED".into(), s.into());
105 }
106 let mut org_tags: Vec<String> = Vec::new();
110 if let Some(tags) = opts.tags {
111 let mut property_tags: Vec<String> = Vec::new();
112 for tag in tags.split([',', ':']).map(str::trim) {
113 if tag.is_empty() {
114 continue;
115 }
116 if tag.chars().all(crate::model::is_org_tag_char) {
117 if !org_tags.iter().any(|seen| seen == tag) {
118 org_tags.push(tag.to_string());
119 }
120 } else if !property_tags.iter().any(|seen| seen == tag) {
121 property_tags.push(tag.to_string());
122 }
123 }
124 if !property_tags.is_empty() {
125 props.insert(crate::model::TAGS_PROPERTY.into(), property_tags.join(","));
126 }
127 }
128 if let Some(p) = opts.parent {
129 props.insert("PARENT".into(), p.into());
130 }
131
132 doc.headings.push(IssueHeading {
133 id: id.clone(),
134 title: title.to_string(),
135 state: "TODO".into(),
136 priority,
137 properties: props,
138 org_tags,
139 property_order: Vec::new(),
140 body: match opts.body {
141 Some(b) if !b.trim().is_empty() => format!("{}\n", b.trim_end()),
142 _ => String::new(),
143 },
144 logbook: Vec::new(),
145 line_start: 0,
146 line_end: 0,
147 });
148 doc.write()?;
149
150 if opts.quiet {
151 Ok(format!("{id}\n"))
152 } else {
153 Ok(format!(
154 "{id} TODO [#{priority}] {title}\nfile: {}\n",
155 path.display()
156 ))
157 }
158 })
159}
160
161pub(crate) fn validate_org_date(s: &str) -> Result<()> {
162 let inner = s
163 .trim_start_matches(['<', '['])
164 .trim_end_matches(['>', ']']);
165 let token = inner.split_whitespace().next().unwrap_or("");
166 NaiveDate::parse_from_str(token, "%Y-%m-%d").with_context(|| {
167 format!("expected org date like <YYYY-MM-DD> or [YYYY-MM-DD], got {s:?}")
168 })?;
169 Ok(())
170}
171
172pub fn update(
180 layout: &Layout,
181 id: &str,
182 new_state: Option<&str>,
183 new_priority: Option<char>,
184 block_add: Option<&str>,
185 block_clear: Option<&str>,
186) -> Result<UpdateOutcome> {
187 let identity = crate::config::identity(layout);
188 update_as(
189 layout,
190 id,
191 new_state,
192 new_priority,
193 block_add,
194 block_clear,
195 &identity,
196 )
197}
198
199pub fn update_as(
206 layout: &Layout,
207 id: &str,
208 new_state: Option<&str>,
209 new_priority: Option<char>,
210 block_add: Option<&str>,
211 block_clear: Option<&str>,
212 identity: &str,
213) -> Result<UpdateOutcome> {
214 let (_h0, path, project) =
215 find_by_id(layout, id)?.ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
216
217 let (final_state, changed) = with_issues_lock(&path, || {
218 let graph = if block_add.is_some() {
221 Some(DependencyGraph::from_issues(&load_all(layout)?)?)
222 } else {
223 None
224 };
225 let mut doc = IssueDoc::parse_file(&project, &path)?;
226 let h = doc
227 .headings
228 .iter_mut()
229 .find(|x| x.id == id)
230 .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
231
232 let mut changed = Vec::new();
233
234 if let Some(s) = new_state {
235 if !TODO_KEYWORDS.contains(&s) {
236 return Err(anyhow!("invalid state {s:?}; allowed: {TODO_KEYWORDS:?}").into());
237 }
238 if h.state != s {
239 let from = h.state.clone();
240 h.record_state_change(s);
241 changed.push(format!("state {from} -> {s}"));
242 for note in settle_claim(h, &from, s, identity) {
243 changed.push(note);
244 }
245 }
246 }
247
248 if let Some(p) = new_priority {
249 if !"ABC".contains(p) {
250 return Err(anyhow!("invalid priority {p:?}; allowed: A B C").into());
251 }
252 if h.priority != p {
253 h.priority = p;
254 changed.push(format!("priority -> [#{p}]"));
255 }
256 }
257
258 if let Some(blk) = block_add {
259 let mut current = h.blocked_by();
260 if !current.iter().any(|x| x == blk) {
261 if let Some(graph) = &graph {
262 graph.accepts_edge(blk, id)?;
263 }
264 current.push(blk.to_string());
265 h.properties.insert("BLOCKED_BY".into(), current.join(","));
266 if h.state == "TODO" || h.state == "STARTED" {
267 let from = h.state.clone();
268 h.record_state_change("BLOCKED");
269 changed.push(format!("state {from} -> BLOCKED (auto on block)"));
270 }
271 changed.push(format!("blocked_by += {blk}"));
272 }
273 }
274
275 if let Some(blk) = block_clear {
276 let mut current = h.blocked_by();
277 let before = current.len();
278 current.retain(|x| x != blk);
279 if current.len() < before {
280 if current.is_empty() {
281 h.properties.remove("BLOCKED_BY");
282 if h.state == "BLOCKED" {
283 let from = h.state.clone();
284 h.record_state_change("TODO");
285 changed.push("state BLOCKED -> TODO (auto on unblock)".to_string());
286 for note in settle_claim(h, &from, "TODO", identity) {
287 changed.push(note);
288 }
289 }
290 } else {
291 h.properties.insert("BLOCKED_BY".into(), current.join(","));
292 }
293 changed.push(format!("blocked_by -= {blk}"));
294 }
295 }
296
297 if changed.is_empty() {
298 return Ok((None, Vec::new()));
299 }
300
301 let final_state = h.state.clone();
302 doc.write()?;
303 Ok((Some(final_state), changed))
304 })?;
305
306 if changed.is_empty() {
307 return Ok(UpdateOutcome {
308 report: format!("{id}: no change\n"),
309 hints: Vec::new(),
310 });
311 }
312
313 let mut hints = Vec::new();
314 if matches!(final_state.as_deref(), Some("DONE") | Some("CANCELLED")) {
315 for (other_project, other) in load_all(layout)? {
316 if !other.blocked_by().iter().any(|b| b == id) {
317 continue;
318 }
319 if other.state == "DONE" || other.state == "CANCELLED" {
320 continue;
321 }
322 hints.push(format!(
323 "{} (in {}) lists this as a blocker; clear with `vissue update {} --unblock {}`",
324 other.id, other_project, other.id, id
325 ));
326 }
327 }
328 Ok(UpdateOutcome {
329 report: format!("{id}: {}\n", changed.join(", ")),
330 hints,
331 })
332}
333
334fn keeps_claim(state: &str) -> bool {
337 matches!(state, "STARTED" | "BLOCKED")
338}
339
340fn settle_claim(h: &mut IssueHeading, from: &str, to: &str, identity: &str) -> Vec<String> {
346 let mut notes = Vec::new();
347 if to == "STARTED" && h.claimed_by().is_none() {
348 h.set_claim(identity);
349 notes.push(format!("claimed by {identity}"));
350 } else if keeps_claim(from)
351 && !keeps_claim(to)
352 && let Some((who, _when)) = h.release_claim()
353 {
354 notes.push(format!("claim released ({who})"));
355 }
356 notes
357}
358
359pub fn claim(layout: &Layout, id: &str, force: bool) -> Result<String> {
370 let identity = crate::config::identity(layout);
371 claim_as(layout, id, force, &identity)
372}
373
374pub fn claim_as(layout: &Layout, id: &str, force: bool, identity: &str) -> Result<String> {
382 let (_h0, path, project) =
383 find_by_id(layout, id)?.ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
384
385 let report = with_issues_lock(&path, || {
386 let mut doc = IssueDoc::parse_file(&project, &path)?;
387 let h = doc
388 .headings
389 .iter_mut()
390 .find(|x| x.id == id)
391 .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
392
393 if h.state == "DONE" || h.state == "CANCELLED" {
394 return Err(Error::InvalidState {
395 id: id.to_string(),
396 state: h.state.clone(),
397 });
398 }
399 if let Some(holder) = h.claimed_by() {
400 if holder != identity && !force {
401 return Err(Error::ClaimConflict {
402 id: id.to_string(),
403 holder: holder.to_string(),
404 claimed_at: h.claimed_at().map(str::to_string),
405 });
406 }
407 if holder != identity {
408 let previous = holder.to_string();
409 h.release_claim();
410 h.set_claim(identity);
411 h.record_state_change("STARTED");
412 doc.write()?;
413 return Ok(format!("claimed {id} (taken over from {previous})\n"));
414 }
415 }
416
417 let was = h.state.clone();
418 h.record_state_change("STARTED");
419 if h.claimed_by().is_none() {
420 h.set_claim(identity);
421 }
422 doc.write()?;
423 if was == "STARTED" {
424 Ok(format!("claimed {id} by {identity}\n"))
425 } else {
426 Ok(format!("claimed {id} by {identity} ({was} -> STARTED)\n"))
427 }
428 })?;
429 Ok(report)
430}
431
432#[derive(Debug, Clone)]
434pub struct UpdateOutcome {
435 pub report: String,
437 pub hints: Vec<String>,
439}
440
441pub fn note(layout: &Layout, id: &str, text: &str) -> Result<String> {
450 let text = text
453 .split_whitespace()
454 .collect::<Vec<_>>()
455 .join(" ")
456 .replace('"', "'");
457 if text.is_empty() {
458 return Err(anyhow!("note text is empty").into());
459 }
460 let (_h0, path, project) =
461 find_by_id(layout, id)?.ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
462 with_issues_lock(&path, || {
463 let mut doc = IssueDoc::parse_file(&project, &path)?;
464 let h = doc
465 .headings
466 .iter_mut()
467 .find(|x| x.id == id)
468 .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
469 h.logbook.insert(
472 0,
473 LogEntry {
474 timestamp: LogEntry::now(),
475 from_state: None,
476 to_state: None,
477 note: Some(text.clone()),
478 raw: None,
479 },
480 );
481 doc.write()?;
482 Ok(format!("{id}: noted\n"))
483 })
484}
485
486pub fn append_body(layout: &Layout, id: &str, text: &str) -> Result<String> {
501 append_body_as(layout, id, text, &crate::config::identity(layout))
502}
503
504pub fn append_body_as(layout: &Layout, id: &str, text: &str, identity: &str) -> Result<String> {
511 let text = text.trim_end();
512 if text.trim().is_empty() {
513 return Err(anyhow!("append text is empty").into());
514 }
515 let (_h0, path, project) =
516 find_by_id(layout, id)?.ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
517 with_issues_lock(&path, || {
518 let mut doc = IssueDoc::parse_file(&project, &path)?;
519 let h = doc
520 .headings
521 .iter_mut()
522 .find(|x| x.id == id)
523 .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
524 let stamp = format!("{} {identity}", today_inactive_bracket());
525 if !h.body.trim().is_empty() {
526 h.body = h.body.trim_end().to_string();
527 h.body.push_str("\n\n");
528 } else {
529 h.body.clear();
530 }
531 h.body.push_str(&stamp);
532 h.body.push('\n');
533 h.body.push_str(text);
534 h.body.push('\n');
535 doc.write()?;
536 let lines = text.lines().count();
537 Ok(format!("{id}: appended {lines} line(s)\n"))
538 })
539}
540
541pub fn fold(layout: &Layout, inbox: &std::path::Path, project: &str) -> Result<String> {
555 let project = resolve_existing_project_case(layout, project)?;
556 let text = std::fs::read_to_string(inbox)
557 .with_context(|| format!("read inbox {}", inbox.display()))?;
558 let lines: Vec<String> = text.lines().map(str::to_string).collect();
559
560 struct Entry {
561 line: usize,
562 title: String,
563 body: String,
564 stamped: bool,
565 }
566 let mut entries: Vec<Entry> = Vec::new();
567 let mut i = 0;
568 while i < lines.len() {
569 if let Some(title) = lines[i].strip_prefix("* TODO ") {
570 let start = i + 1;
571 let end = lines[start..]
572 .iter()
573 .position(|l| l.starts_with("* "))
574 .map(|off| start + off)
575 .unwrap_or(lines.len());
576 let stamped = lines[start..end]
577 .iter()
578 .any(|l| l.trim_start().starts_with(":VISSUE_ID:"));
579 let body = lines[start..end].join("\n").trim().to_string();
580 entries.push(Entry {
581 line: i,
582 title: title.trim().to_string(),
583 body,
584 stamped,
585 });
586 i = end;
587 } else {
588 i += 1;
589 }
590 }
591
592 let mut out = lines.clone();
595 let mut created: Vec<String> = Vec::new();
596 let mut failure = None;
597 for e in entries.iter().rev() {
598 if e.stamped {
599 continue;
600 }
601 let printed = create(
602 layout,
603 &project,
604 &e.title,
605 CreateOpts {
606 quiet: true,
607 body: if e.body.is_empty() {
608 None
609 } else {
610 Some(&e.body)
611 },
612 ..CreateOpts::default()
613 },
614 );
615 let id = match printed {
616 Ok(printed) => printed.trim().to_string(),
617 Err(e) => {
618 failure = Some(e);
622 break;
623 }
624 };
625 out[e.line] = format!("* DONE {}", e.title);
626 out.insert(e.line + 1, format!(":VISSUE_ID: {id}"));
627 created.push(id);
628 }
629 created.reverse();
630
631 if !created.is_empty() {
632 let mut rendered = out.join("\n");
633 if text.ends_with('\n') {
634 rendered.push('\n');
635 }
636 std::fs::write(inbox, rendered)
637 .with_context(|| format!("write inbox {}", inbox.display()))?;
638 }
639 if let Some(error) = failure {
640 return Err(crate::error::Error::Other(
641 anyhow::Error::from(error).context(format!(
642 "folded {} before failing: {}",
643 created.len(),
644 created.join(" ")
645 )),
646 ));
647 }
648 if created.is_empty() {
649 return Ok("folded 0 (nothing unstamped)\n".into());
650 }
651 Ok(format!("folded {}: {}\n", created.len(), created.join(" ")))
652}
653
654pub fn refile(layout: &Layout, id: &str, to_project: &str) -> Result<String> {
662 let to_project = resolve_existing_project_case(layout, to_project)?;
663 let target_path = layout.project_issues_path(&to_project);
664 let (_heading, src_path, src_project) =
665 find_by_id(layout, id)?.ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
666 if src_project == to_project {
667 return Ok(format!("{id} already in {to_project}; nothing to do\n"));
668 }
669 with_issues_locks(&[&src_path, &target_path], || {
670 let mut src_doc = IssueDoc::parse_file(&src_project, &src_path)?;
671 let heading = src_doc
672 .remove(id)
673 .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
674
675 let mut tgt_doc = IssueDoc::parse_file(&to_project, &target_path)?;
681 tgt_doc.upsert(heading);
682 tgt_doc.write()?;
683 src_doc.write()?;
684 Ok(())
685 })?;
686 Ok(format!("{id}: {src_project} -> {to_project}\n"))
687}
688
689#[cfg(test)]
690mod tests {
691 use super::*;
692 use crate::config::DEFAULT_PREFIX;
693 use std::fs;
694 use std::path::Path;
695
696 fn fresh_layout(dir: &Path) -> Layout {
697 fs::create_dir_all(dir.join(DEFAULT_PREFIX)).unwrap();
698 Layout::new(dir, DEFAULT_PREFIX)
699 }
700
701 fn issue_at(layout: &Layout, project: &str, id: &str) -> IssueHeading {
702 IssueDoc::parse_file(project, &layout.project_issues_path(project))
703 .unwrap()
704 .headings
705 .into_iter()
706 .find(|h| h.id == id)
707 .expect("issue not found")
708 }
709
710 fn only_id(layout: &Layout, project: &str) -> String {
711 IssueDoc::parse_file(project, &layout.project_issues_path(project))
712 .unwrap()
713 .headings[0]
714 .id
715 .clone()
716 }
717
718 #[test]
719 fn create_rejects_a_parent_that_does_not_exist() {
720 let dir = tempfile::tempdir().unwrap();
721 let layout = fresh_layout(dir.path());
722 let err = create(
723 &layout,
724 "sample",
725 "child without parent",
726 CreateOpts {
727 parent: Some("sample-zzz9"),
728 ..Default::default()
729 },
730 )
731 .unwrap_err();
732 assert!(err.to_string().contains("does not refer to any known id"));
733 }
734
735 #[test]
736 fn create_accepts_a_parent_defined_in_a_design_document() {
737 let dir = tempfile::tempdir().unwrap();
738 let layout = fresh_layout(dir.path());
739 let parent_id = "sample-spec-20260615";
740 let project_dir = layout.projects_dir().join("sample");
741 fs::create_dir_all(&project_dir).unwrap();
742 fs::write(
743 project_dir.join("design.org"),
744 format!("#+TITLE: sample design\n\n* Design\n:PROPERTIES:\n:ID: {parent_id}\n:END:\n"),
745 )
746 .unwrap();
747
748 create(
749 &layout,
750 "sample",
751 "child under design",
752 CreateOpts {
753 parent: Some(parent_id),
754 ..Default::default()
755 },
756 )
757 .unwrap();
758 assert!(only_id(&layout, "sample").starts_with("sample-"));
759 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
760 assert_eq!(doc.headings[0].parent(), Some(parent_id));
761 }
762
763 #[test]
764 fn a_state_update_writes_a_logbook_entry() {
765 let dir = tempfile::tempdir().unwrap();
766 let layout = fresh_layout(dir.path());
767 create(&layout, "sample", "first", CreateOpts::default()).unwrap();
768 let id = only_id(&layout, "sample");
769 update(&layout, &id, Some("STARTED"), None, None, None).unwrap();
770 let h = issue_at(&layout, "sample", &id);
771 assert_eq!(h.state, "STARTED");
772 assert_eq!(h.logbook[0].from_state.as_deref(), Some("TODO"));
773 assert_eq!(h.logbook[0].to_state.as_deref(), Some("STARTED"));
774 }
775
776 #[test]
777 fn blocking_and_unblocking_drive_the_state() {
778 let dir = tempfile::tempdir().unwrap();
779 let layout = fresh_layout(dir.path());
780 create(&layout, "sample", "first", CreateOpts::default()).unwrap();
781 create(&layout, "sample", "blocker", CreateOpts::default()).unwrap();
782 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
783 let first = doc.headings[0].id.clone();
784 let blocker = doc.headings[1].id.clone();
785
786 update(&layout, &first, None, None, Some(&blocker), None).unwrap();
787 let h = issue_at(&layout, "sample", &first);
788 assert_eq!(h.state, "BLOCKED");
789 assert!(h.blocked_by().contains(&blocker));
790
791 update(&layout, &first, None, None, None, Some(&blocker)).unwrap();
792 let h = issue_at(&layout, "sample", &first);
793 assert_eq!(h.state, "TODO");
794 assert!(h.blocked_by().is_empty());
795 }
796
797 #[test]
798 fn auto_unblock_to_todo_releases_the_claim() {
799 let dir = tempfile::tempdir().unwrap();
800 let layout = fresh_layout(dir.path());
801 create(&layout, "sample", "first", CreateOpts::default()).unwrap();
802 create(&layout, "sample", "blocker", CreateOpts::default()).unwrap();
803 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
804 let first = doc.headings[0].id.clone();
805 let blocker = doc.headings[1].id.clone();
806
807 crate::agent::claim(&layout, &first, false).unwrap();
808 update(&layout, &first, None, None, Some(&blocker), None).unwrap();
809 assert!(issue_at(&layout, "sample", &first).claimed_by().is_some());
810
811 update(&layout, &first, None, None, None, Some(&blocker)).unwrap();
812 let h = issue_at(&layout, "sample", &first);
813 assert_eq!(h.state, "TODO");
814 assert!(h.claimed_by().is_none(), "claim stuck on TODO: {h:?}");
815 }
816
817 #[test]
818 fn blocker_cycle_is_rejected_before_writing() {
819 let dir = tempfile::tempdir().unwrap();
820 let layout = fresh_layout(dir.path());
821 create(&layout, "sample", "first", CreateOpts::default()).unwrap();
822 create(&layout, "sample", "second", CreateOpts::default()).unwrap();
823 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
824 let first = doc.headings[0].id.clone();
825 let second = doc.headings[1].id.clone();
826
827 update(&layout, &first, None, None, Some(&second), None).unwrap();
828 let err = update(&layout, &second, None, None, Some(&first), None).unwrap_err();
829 assert!(err.to_string().contains("blocker cycle"), "{err}");
830 assert!(issue_at(&layout, "sample", &second).blocked_by().is_empty());
831 }
832
833 #[test]
834 fn closing_a_blocker_reports_the_issues_still_pointing_at_it() {
835 let dir = tempfile::tempdir().unwrap();
836 let layout = fresh_layout(dir.path());
837 create(&layout, "sample", "first", CreateOpts::default()).unwrap();
838 create(&layout, "sample", "blocker", CreateOpts::default()).unwrap();
839 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
840 let first = doc.headings[0].id.clone();
841 let blocker = doc.headings[1].id.clone();
842 update(&layout, &first, None, None, Some(&blocker), None).unwrap();
843
844 let outcome = update(&layout, &blocker, Some("DONE"), None, None, None).unwrap();
845 assert_eq!(outcome.hints.len(), 1, "{:?}", outcome.hints);
846 assert!(outcome.hints[0].contains(&first), "{:?}", outcome.hints);
847 }
848
849 #[test]
850 fn refile_moves_the_heading_between_projects() {
851 let dir = tempfile::tempdir().unwrap();
852 let layout = fresh_layout(dir.path());
853 create(&layout, "source", "the issue", CreateOpts::default()).unwrap();
854 let id = only_id(&layout, "source");
855 refile(&layout, &id, "target").unwrap();
856
857 let src = IssueDoc::parse_file("source", &layout.project_issues_path("source")).unwrap();
858 let tgt = IssueDoc::parse_file("target", &layout.project_issues_path("target")).unwrap();
859 assert!(src.headings.is_empty());
860 assert_eq!(tgt.headings[0].id, id);
861 }
862
863 #[test]
864 fn deadlines_must_parse_as_org_dates() {
865 let dir = tempfile::tempdir().unwrap();
866 let layout = fresh_layout(dir.path());
867 let err = create(
868 &layout,
869 "sample",
870 "bad date",
871 CreateOpts {
872 deadline: Some("not-a-date"),
873 ..Default::default()
874 },
875 )
876 .unwrap_err();
877 assert!(err.to_string().contains("expected org date"));
878
879 for (i, d) in ["<2026-05-15 Fri>", "[2026-05-15]"].iter().enumerate() {
880 create(
881 &layout,
882 "sample",
883 &format!("issue {i}"),
884 CreateOpts {
885 deadline: Some(d),
886 ..Default::default()
887 },
888 )
889 .unwrap();
890 }
891 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
892 assert_eq!(doc.headings.len(), 2);
893 assert!(doc.headings.iter().all(|h| h.deadline().is_some()));
894 }
895
896 #[test]
897 fn org_safe_tags_go_on_the_heading_and_the_rest_stay_in_the_property() {
898 let dir = tempfile::tempdir().unwrap();
899 let layout = fresh_layout(dir.path());
900 create(
901 &layout,
902 "sample",
903 "tagged",
904 CreateOpts {
905 tags: Some("rust: perf ,, scaling, needs-review"),
906 ..Default::default()
907 },
908 )
909 .unwrap();
910 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
911 let h = &doc.headings[0];
912 assert_eq!(h.org_tags, vec!["rust", "perf", "scaling"]);
913 assert_eq!(
914 h.properties
915 .get(crate::model::TAGS_PROPERTY)
916 .map(|s| s.as_str()),
917 Some("needs-review"),
918 "a tag Org cannot hold keeps the property"
919 );
920 assert_eq!(
922 h.tags(),
923 vec!["needs-review", "rust", "perf", "scaling"],
924 "{h:?}"
925 );
926 }
927
928 #[test]
929 fn resolve_project_needs_a_name_from_somewhere() {
930 let dir = tempfile::tempdir().unwrap();
931 let layout = fresh_layout(dir.path());
932 assert_eq!(
933 resolve_project(&layout, Some("fromcli")).unwrap(),
934 "fromcli"
935 );
936 assert!(
937 resolve_project(&layout, Some(""))
938 .unwrap_err()
939 .to_string()
940 .contains("empty")
941 );
942 }
943
944 #[test]
946 fn concurrent_creates_preserve_every_heading() {
947 use std::sync::Arc;
948 use std::thread;
949
950 let dir = tempfile::tempdir().unwrap();
951 let layout = Arc::new(fresh_layout(dir.path()));
952 let n = 24usize;
953 let handles: Vec<_> = (0..n)
954 .map(|i| {
955 let layout = Arc::clone(&layout);
956 thread::spawn(move || {
957 create(
958 &layout,
959 "sample",
960 &format!("parallel title {i}"),
961 CreateOpts {
962 quiet: true,
963 ..Default::default()
964 },
965 )
966 })
967 })
968 .collect();
969 let mut ids: Vec<String> = handles
970 .into_iter()
971 .map(|h| {
972 h.join()
973 .expect("thread panicked")
974 .expect("create failed")
975 .trim()
976 .to_string()
977 })
978 .collect();
979 ids.sort();
980 ids.dedup();
981 assert_eq!(ids.len(), n, "expected {n} unique ids, got {ids:?}");
982
983 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
984 let mut on_disk: Vec<String> = doc.headings.iter().map(|h| h.id.clone()).collect();
985 on_disk.sort();
986 assert_eq!(on_disk, ids);
987 }
988
989 #[test]
990 fn note_appends_to_the_logbook_and_leaves_state_alone() {
991 let dir = tempfile::tempdir().unwrap();
992 let layout = fresh_layout(dir.path());
993 create(&layout, "sample", "carries a note", CreateOpts::default()).unwrap();
994 let id = only_id(&layout, "sample");
995
996 let out = note(&layout, &id, "first pass done,\n \"quoted\" bit next").unwrap();
997 assert_eq!(out, format!("{id}: noted\n"));
998
999 let h = issue_at(&layout, "sample", &id);
1000 assert_eq!(h.state, "TODO");
1001 assert!(h.claimed_by().is_none());
1002 let notes: Vec<&str> = h.logbook.iter().filter_map(|e| e.note.as_deref()).collect();
1003 assert_eq!(notes, vec!["first pass done, 'quoted' bit next"]);
1005 }
1006
1007 #[test]
1008 fn the_logbook_reads_newest_first_however_an_entry_arrived() {
1009 let dir = tempfile::tempdir().unwrap();
1010 let layout = fresh_layout(dir.path());
1011 create(&layout, "sample", "ordered", CreateOpts::default()).unwrap();
1012 let id = only_id(&layout, "sample");
1013
1014 note(&layout, &id, "first note").unwrap();
1015 update(&layout, &id, Some("STARTED"), None, None, None).unwrap();
1016 note(&layout, &id, "second note").unwrap();
1017
1018 let h = issue_at(&layout, "sample", &id);
1019 let summary: Vec<String> = h
1020 .logbook
1021 .iter()
1022 .map(|e| match (&e.note, &e.to_state) {
1023 (Some(note), _) => note.clone(),
1024 (_, Some(to)) => format!("state:{to}"),
1025 _ => "?".into(),
1026 })
1027 .collect();
1028 assert_eq!(
1029 summary,
1030 vec!["second note", "state:STARTED", "first note"],
1031 "{h:?}"
1032 );
1033 }
1034
1035 #[test]
1036 fn note_rejects_empty_text_and_unknown_ids() {
1037 let dir = tempfile::tempdir().unwrap();
1038 let layout = fresh_layout(dir.path());
1039 create(&layout, "sample", "target", CreateOpts::default()).unwrap();
1040 let id = only_id(&layout, "sample");
1041 assert!(note(&layout, &id, " ").is_err());
1042 assert!(note(&layout, "sample-zzz9", "text").is_err());
1043 }
1044
1045 #[test]
1046 fn fold_creates_issues_and_stamps_the_inbox_idempotently() {
1047 let dir = tempfile::tempdir().unwrap();
1048 let layout = fresh_layout(dir.path());
1049 create(&layout, "sample", "seed", CreateOpts::default()).unwrap();
1050
1051 let inbox = dir.path().join("inbox.org");
1052 fs::write(
1053 &inbox,
1054 "#+TITLE: inbox\n\n\
1055 * TODO first discovered thing\nSome body line.\nAnother line.\n\
1056 * DONE already handled elsewhere\n\
1057 * TODO second discovered thing\n",
1058 )
1059 .unwrap();
1060
1061 let out = fold(&layout, &inbox, "sample").unwrap();
1062 assert!(out.starts_with("folded 2: "), "got: {out}");
1063
1064 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1065 let titles: Vec<&str> = doc.headings.iter().map(|h| h.title.as_str()).collect();
1066 assert!(titles.contains(&"first discovered thing"));
1067 assert!(titles.contains(&"second discovered thing"));
1068 let folded = doc
1069 .headings
1070 .iter()
1071 .find(|h| h.title == "first discovered thing")
1072 .unwrap();
1073 assert!(folded.body.contains("Some body line."));
1074
1075 let stamped = fs::read_to_string(&inbox).unwrap();
1077 assert_eq!(stamped.matches("* DONE ").count(), 3);
1078 assert_eq!(stamped.matches(":VISSUE_ID: sample-").count(), 2);
1079 assert!(!stamped.contains("* TODO "));
1080
1081 let again = fold(&layout, &inbox, "sample").unwrap();
1083 assert_eq!(again, "folded 0 (nothing unstamped)\n");
1084 let doc2 = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1085 assert_eq!(doc2.headings.len(), doc.headings.len());
1086 }
1087}