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