1use anyhow::{Context, anyhow};
4
5use crate::error::Result;
6use fs2::FileExt;
7use std::collections::{BTreeMap, HashMap};
8use std::fs;
9use std::path::{Path, PathBuf};
10use std::sync::atomic::{AtomicU64, Ordering};
11use std::sync::{Arc, Mutex, OnceLock};
12use std::time::{SystemTime, UNIX_EPOCH};
13use xxhash_rust::xxh3::xxh3_64_with_seed;
14
15use crate::config::Layout;
16use crate::model::{IssueHeading, LogEntry, TODO_HEADER, parse_log_line, today_inactive_bracket};
17use crate::org::{
18 OrgScan, TagSettings, ensure_org_preamble, is_headline, is_issue_headline, is_planning_line,
19 is_top_level_headline, opens_a_drawer, parse_headline_bits, parse_planning_line,
20 property_key_and_append, split_statistics_cookies, tag_settings_from_preamble,
21 todo_keywords_from_lines,
22};
23
24static PROCESS_LOCKS: OnceLock<Mutex<HashMap<PathBuf, Arc<Mutex<()>>>>> = OnceLock::new();
28static WRITE_TMP_SEQ: AtomicU64 = AtomicU64::new(0);
29
30const ID_ALPHABET: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz";
31
32struct CrossProcessLock {
33 file: fs::File,
34}
35
36impl CrossProcessLock {
37 fn acquire(path: &Path) -> Result<Self> {
38 let lock_path = issues_lock_path(path);
39 if let Some(parent) = lock_path.parent() {
40 fs::create_dir_all(parent)
41 .with_context(|| format!("create lock parent {}", parent.display()))?;
42 }
43 let file = fs::OpenOptions::new()
44 .create(true)
45 .read(true)
46 .append(true)
47 .open(&lock_path)
48 .with_context(|| format!("open lock {}", lock_path.display()))?;
49 file.lock_exclusive()
50 .with_context(|| format!("lock {}", lock_path.display()))?;
51 Ok(Self { file })
52 }
53}
54
55impl Drop for CrossProcessLock {
56 fn drop(&mut self) {
57 let _ = fs2::FileExt::unlock(&self.file);
58 }
59}
60
61fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
64 use std::io::Write as _;
65 let mut file = fs::File::create(path)?;
66 file.write_all(bytes)?;
67 file.sync_all()
68}
69
70pub fn replace_file_atomically(path: &Path, body: &str) -> Result<()> {
81 let parent = path
82 .parent()
83 .filter(|p| !p.as_os_str().is_empty())
84 .map(Path::to_path_buf)
85 .unwrap_or_else(|| PathBuf::from("."));
86 fs::create_dir_all(&parent).with_context(|| format!("create {}", parent.display()))?;
87 let seq = WRITE_TMP_SEQ.fetch_add(1, Ordering::Relaxed);
88 let base = path
89 .file_name()
90 .and_then(|s| s.to_str())
91 .unwrap_or("output");
92 let tmp = parent.join(format!(".{}.tmp.{}-{}", base, std::process::id(), seq));
93 if let Err(e) = write_synced(&tmp, body.as_bytes()) {
94 let _ = fs::remove_file(&tmp);
95 return Err(e)
96 .with_context(|| format!("write temp {}", tmp.display()))
97 .map_err(crate::error::Error::from);
98 }
99 if let Err(e) = fs::rename(&tmp, path) {
100 let _ = fs::remove_file(&tmp);
101 return Err(e)
102 .with_context(|| format!("rename {} -> {}", tmp.display(), path.display()))
103 .map_err(crate::error::Error::from);
104 }
105 Ok(())
106}
107
108fn issues_lock_path(path: &Path) -> PathBuf {
109 let mut s = path.as_os_str().to_owned();
110 s.push(".lock");
111 PathBuf::from(s)
112}
113
114fn process_mutex_for(path: &Path) -> Arc<Mutex<()>> {
115 let key = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
116 let mut map = PROCESS_LOCKS
117 .get_or_init(|| Mutex::new(HashMap::new()))
118 .lock()
119 .unwrap_or_else(|p| p.into_inner());
120 map.entry(key)
121 .or_insert_with(|| Arc::new(Mutex::new(())))
122 .clone()
123}
124
125pub fn with_issues_lock<R, F>(path: &Path, f: F) -> Result<R>
135where
136 F: FnOnce() -> Result<R>,
137{
138 let mutex = process_mutex_for(path);
139 let _proc = mutex.lock().unwrap_or_else(|p| p.into_inner());
140 let _cross = CrossProcessLock::acquire(path)?;
141 f()
142}
143
144pub fn with_issues_locks<R, F>(paths: &[&Path], f: F) -> Result<R>
152where
153 F: FnOnce() -> Result<R>,
154{
155 let mut seen: Vec<PathBuf> = Vec::new();
158 let mut keys: Vec<PathBuf> = Vec::new();
159 let mut ordered: Vec<PathBuf> = paths.iter().map(|p| (*p).to_path_buf()).collect();
160 ordered.sort();
161 for path in ordered {
162 let identity = path.canonicalize().unwrap_or_else(|_| path.clone());
163 if seen.contains(&identity) {
164 continue;
165 }
166 seen.push(identity);
167 keys.push(path);
168 }
169 let mutexes: Vec<Arc<Mutex<()>>> = keys.iter().map(|k| process_mutex_for(k)).collect();
170 let mut proc_guards = Vec::with_capacity(mutexes.len());
171 let mut cross_guards = Vec::with_capacity(keys.len());
172 for (key, mutex) in keys.iter().zip(mutexes.iter()) {
173 proc_guards.push(mutex.lock().unwrap_or_else(|p| p.into_inner()));
174 cross_guards.push(CrossProcessLock::acquire(key)?);
175 }
176 f()
177}
178
179fn checkbox_counts(body: &str) -> (usize, usize) {
182 let mut total = 0;
183 let mut checked = 0;
184 for line in body.lines() {
185 let t = line.trim_start();
186 let Some(rest) = t
187 .strip_prefix("- ")
188 .or_else(|| t.strip_prefix("+ "))
189 .or_else(|| t.strip_prefix("* "))
190 .or_else(|| {
191 t.split_once(". ")
192 .filter(|(n, _)| n.chars().all(|c| c.is_ascii_digit()))
193 .map(|(_, r)| r)
194 })
195 else {
196 continue;
197 };
198 if rest.starts_with("[ ]") || rest.starts_with("[-]") {
199 total += 1;
200 } else if rest.starts_with("[X]") || rest.starts_with("[x]") {
201 total += 1;
202 checked += 1;
203 }
204 }
205 (total, checked)
206}
207
208#[derive(Debug, Clone)]
210pub struct IssueDoc {
211 pub project: String,
213 pub path: PathBuf,
215 pub preamble: String,
217 pub tag_settings: TagSettings,
219 pub keywords: crate::org::TodoSequence,
221 pub headings: Vec<IssueHeading>,
223 after: Vec<String>,
227}
228
229impl IssueDoc {
230 pub fn empty(project: &str, path: PathBuf) -> Self {
232 let preamble = default_preamble(project);
233 IssueDoc {
234 project: project.to_string(),
235 path,
236 tag_settings: tag_settings_from_preamble(&preamble),
237 preamble,
238 keywords: crate::org::TodoSequence::house(),
239 headings: Vec::new(),
240 after: Vec::new(),
241 }
242 }
243
244 pub fn parse_file(project: &str, path: &Path) -> Result<Self> {
250 if !path.exists() {
251 return Ok(Self::empty(project, path.to_path_buf()));
252 }
253 let content =
254 fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
255 Self::parse(project, path.to_path_buf(), &content)
256 }
257
258 pub fn parse(project: &str, path: PathBuf, content: &str) -> Result<Self> {
266 let lines: Vec<&str> = content.lines().collect();
267 let preamble_end = {
268 let mut nest = OrgScan::new();
269 lines
270 .iter()
271 .position(|line| {
272 if nest.observe(line) {
273 return false;
274 }
275 is_headline(line)
276 })
277 .unwrap_or(lines.len())
278 };
279 let raw_preamble = if preamble_end == 0 {
280 String::new()
281 } else {
282 lines[..preamble_end].join("\n").trim_end().to_string()
283 };
284 let settings = crate::org::merge_setupfile_settings(&raw_preamble, path.parent());
285 let keyword_lines: Vec<&str> = settings.lines().chain(lines.iter().copied()).collect();
289 let keywords = todo_keywords_from_lines(&keyword_lines);
290 let sequence = crate::org::todo_sequence_from_lines(&keyword_lines);
291 let headline_at: Vec<bool> = (0..lines.len())
294 .map(|i| is_vissue_headline(&lines, i, &keywords))
295 .collect();
296 let mut nest = OrgScan::new();
297 let first_heading = lines
298 .iter()
299 .enumerate()
300 .position(|(i, line)| {
301 if nest.observe(line) {
302 return false;
303 }
304 headline_at[i]
305 })
306 .unwrap_or(lines.len());
307 let preamble = if first_heading == 0 {
308 default_preamble(project)
309 } else {
310 lines[..first_heading].join("\n").trim_end().to_string()
311 };
312 let default_priority = crate::org::priorities_from_preamble(&settings).default;
314 let mut headings = Vec::new();
315 let mut after = Vec::new();
316 let mut i = first_heading;
317 while i < lines.len() {
318 if !headline_at[i] {
319 i += 1;
320 continue;
321 }
322 let (heading, body_end) = parse_heading(&lines, i, &keywords, default_priority)
323 .with_context(|| format!("at {}:{}", path.display(), i + 1))?;
324 headings.push(heading);
325 i = body_end;
326 let inter_start = i;
327 let mut nest = OrgScan::new();
328 while i < lines.len() {
329 if !nest.observe(lines[i]) && headline_at[i] {
330 break;
331 }
332 i += 1;
333 }
334 let raw = lines[inter_start..i].join("\n");
335 after.push(if raw.trim().is_empty() {
336 String::new()
337 } else {
338 raw.trim_start_matches('\n').to_string()
339 });
340 }
341 let parent = path.parent().map(std::path::Path::to_path_buf);
342 Ok(IssueDoc {
343 project: project.to_string(),
344 path,
345 tag_settings: tag_settings_from_preamble(&crate::org::merge_setupfile_settings(
346 &preamble,
347 parent.as_deref(),
348 )),
349 preamble,
350 keywords: sequence,
351 headings,
352 after,
353 })
354 }
355
356 pub fn render_string(&self) -> String {
358 let mut out = String::new();
359 let preamble = if self.preamble.trim().is_empty() {
360 default_preamble(&self.project)
361 } else {
362 ensure_org_preamble(&self.preamble, &self.project)
363 };
364 out.push_str(preamble.trim_end());
365 out.push_str("\n\n");
366 for (i, h) in self.headings.iter().enumerate() {
367 out.push_str(&h.render());
368 out.push('\n');
369 if let Some(extra) = self.after.get(i)
370 && !extra.is_empty()
371 {
372 out.push_str(extra);
373 if !extra.ends_with('\n') {
374 out.push('\n');
375 }
376 }
377 }
378 out
379 }
380
381 pub fn refresh_statistics(&mut self) {
387 let done: Vec<bool> = self
388 .headings
389 .iter()
390 .map(|h| self.keywords.is_done(&h.state))
391 .collect();
392 let parents: Vec<Option<String>> = self
393 .headings
394 .iter()
395 .map(|h| h.properties.get("PARENT").map(|p| p.trim().to_string()))
396 .collect();
397 let mut updates = Vec::new();
398 for (i, h) in self.headings.iter().enumerate() {
399 let Some(cookie) = h.statistics.as_deref() else {
400 continue;
401 };
402 let data = h
403 .properties
404 .get("COOKIE_DATA")
405 .map(|v| v.to_ascii_lowercase())
406 .unwrap_or_default();
407 let (total, finished) = if data.contains("checkbox") {
408 checkbox_counts(&h.body)
409 } else {
410 let mut wanted: Vec<usize> = Vec::new();
411 let mut frontier = vec![h.id.clone()];
412 while let Some(id) = frontier.pop() {
413 for (j, parent) in parents.iter().enumerate() {
414 if parent.as_deref() == Some(id.as_str()) && !wanted.contains(&j) {
415 wanted.push(j);
416 if data.contains("recursive") {
417 frontier.push(self.headings[j].id.clone());
418 }
419 }
420 }
421 }
422 (wanted.len(), wanted.iter().filter(|j| done[**j]).count())
423 };
424 let filled = if cookie.contains('%') {
425 let pct = (finished * 100).checked_div(total).unwrap_or(0);
426 format!("[{pct}%]")
427 } else {
428 format!("[{finished}/{total}]")
429 };
430 if filled != cookie {
431 updates.push((i, filled));
432 }
433 }
434 for (i, filled) in updates {
435 self.headings[i].statistics = Some(filled);
436 }
437 }
438
439 pub fn write(&self) -> Result<()> {
447 if let Some(parent) = self.path.parent() {
448 fs::create_dir_all(parent)?;
449 }
450 let mut doc = self.clone();
451 doc.refresh_statistics();
452 let out = doc.render_string();
453 let seq = WRITE_TMP_SEQ.fetch_add(1, Ordering::Relaxed);
456 let nanos = SystemTime::now()
457 .duration_since(UNIX_EPOCH)
458 .map(|d| d.as_nanos())
459 .unwrap_or(0);
460 let base = self
461 .path
462 .file_name()
463 .and_then(|s| s.to_str())
464 .unwrap_or("issues.org");
465 let tmp = self
466 .path
467 .parent()
468 .unwrap_or_else(|| Path::new("."))
469 .join(format!(
470 ".{}.tmp.{}-{}-{}",
471 base,
472 std::process::id(),
473 nanos,
474 seq
475 ));
476 if let Err(e) = write_synced(&tmp, out.as_bytes()) {
480 let _ = fs::remove_file(&tmp);
481 return Err(e)
482 .with_context(|| format!("write temp {}", tmp.display()))
483 .map_err(crate::error::Error::from);
484 }
485 if let Err(e) = fs::rename(&tmp, &self.path) {
486 let _ = fs::remove_file(&tmp);
487 return Err(e)
488 .with_context(|| format!("rename {} -> {}", tmp.display(), self.path.display()))
489 .map_err(crate::error::Error::from);
490 }
491 self.announce_write();
492 Ok(())
493 }
494
495 fn announce_write(&self) {
499 if !crate::events::enabled() {
500 return;
501 }
502 let Some(dir) = self.path.parent().and_then(|p| p.parent()) else {
503 return;
504 };
505 let _ = crate::events::emit_issues_write(dir, &self.project, &self.path);
506 let _ = crate::events::ensure_gitignore_hint(dir);
507 }
508
509 pub fn priority_spec(&self) -> crate::org::PrioritySpec {
511 crate::org::priorities_from_preamble(&self.merged_inbuffer_settings())
512 }
513
514 pub fn priorities_are_named(&self) -> bool {
516 crate::org::preamble_has_keyword(&self.merged_inbuffer_settings(), "PRIORITIES")
517 }
518
519 pub fn default_create_priority(&self, cfg_default: char) -> char {
521 if self.priorities_are_named() {
522 self.priority_spec().default
523 } else {
524 cfg_default
525 }
526 }
527
528 fn merged_inbuffer_settings(&self) -> String {
529 crate::org::merge_setupfile_settings(&self.preamble, self.path.parent())
530 }
531
532 pub fn known_ids(&self) -> Vec<String> {
534 self.headings.iter().map(|h| h.id.clone()).collect()
535 }
536
537 pub fn upsert(&mut self, heading: IssueHeading) {
539 if let Some(slot) = self.headings.iter_mut().find(|h| h.id == heading.id) {
540 *slot = heading;
541 } else {
542 self.headings.push(heading);
543 self.after.push(String::new());
544 }
545 }
546
547 pub fn remove(&mut self, id: &str) -> Option<IssueHeading> {
549 let idx = self.headings.iter().position(|h| h.id == id)?;
550 let heading = self.headings.remove(idx);
551 let extra = if idx < self.after.len() {
552 self.after.remove(idx)
553 } else {
554 String::new()
555 };
556 if !extra.trim().is_empty() {
557 if idx == 0 {
558 if !self.preamble.is_empty() && !self.preamble.ends_with('\n') {
559 self.preamble.push('\n');
560 }
561 if !self.preamble.is_empty() {
562 self.preamble.push('\n');
563 }
564 self.preamble.push_str(&extra);
565 } else if let Some(prev) = self.after.get_mut(idx - 1) {
566 if !prev.is_empty() && !prev.ends_with('\n') {
567 prev.push('\n');
568 }
569 prev.push_str(&extra);
570 }
571 }
572 Some(heading)
573 }
574}
575
576fn is_vissue_headline(lines: &[&str], i: usize, keywords: &[String]) -> bool {
577 if !is_issue_headline(lines[i], keywords) {
578 return false;
579 }
580 peek_heading_id(lines, i).is_none_or(|id| !crate::org::is_gcal_event_id(id))
581}
582
583fn peek_heading_id<'a>(lines: &[&'a str], start: usize) -> Option<&'a str> {
586 let mut i = start + 1;
587 while i < lines.len() && !parse_planning_line(lines[i]).is_empty() {
588 i += 1;
589 }
590 while i < lines.len() {
591 let trimmed = lines[i].trim();
592 if trimmed.is_empty() {
593 i += 1;
594 continue;
595 }
596 if !opens_a_drawer(trimmed) {
597 break;
598 }
599 if trimmed.eq_ignore_ascii_case(":PROPERTIES:") {
600 i += 1;
601 while i < lines.len() && !lines[i].trim().eq_ignore_ascii_case(":END:") {
602 let line = lines[i].trim();
603 if let Some(rest) = line.strip_prefix(':')
604 && let Some((key, value)) = rest.split_once(':')
605 {
606 let (key, _) = property_key_and_append(key.trim());
607 if key.eq_ignore_ascii_case("ID") {
608 let value = value.trim();
609 if !value.is_empty() {
610 return Some(value);
611 }
612 }
613 }
614 i += 1;
615 }
616 return None;
617 }
618 i += 1;
619 while i < lines.len() && !lines[i].trim().eq_ignore_ascii_case(":END:") {
620 i += 1;
621 }
622 if i < lines.len() {
623 i += 1;
624 }
625 }
626 None
627}
628
629fn parse_heading(
630 lines: &[&str],
631 start: usize,
632 keywords: &[String],
633 default_priority: char,
634) -> Result<(IssueHeading, usize)> {
635 let header = lines[start];
636 let stripped = header
637 .strip_prefix("* ")
638 .ok_or_else(|| anyhow!("not a heading"))?;
639 let bits = parse_headline_bits(stripped, keywords);
640 let state = bits
641 .keyword
642 .ok_or_else(|| anyhow!("not an issue heading"))?
643 .to_string();
644 let priority = bits.priority.unwrap_or(default_priority);
645 let (title_and_cookies, org_tags) = crate::model::split_headline_tags(bits.rest);
646 let (title, statistics) = split_statistics_cookies(&title_and_cookies);
647
648 let mut properties = BTreeMap::new();
649 let mut property_order = Vec::new();
650 let mut logbook: Vec<LogEntry> = Vec::new();
651 let mut extra_drawers: Vec<String> = Vec::new();
652 let mut i = start + 1;
653
654 while i < lines.len() {
658 let found = parse_planning_line(lines[i]);
659 if found.is_empty() {
660 break;
661 }
662 for (key, value) in found {
663 if !property_order.contains(&key) {
664 property_order.push(key.clone());
665 }
666 properties.insert(key, value);
667 }
668 i += 1;
669 }
670
671 while i < lines.len() {
672 let trimmed = lines[i].trim();
673 if trimmed.is_empty() {
674 i += 1;
675 continue;
676 }
677 if !opens_a_drawer(trimmed) {
678 break;
679 }
680 if trimmed.eq_ignore_ascii_case(":PROPERTIES:") {
681 i += 1;
682 while i < lines.len() && !lines[i].trim().eq_ignore_ascii_case(":END:") {
683 let line = lines[i].trim();
684 if let Some(rest) = line.strip_prefix(':')
685 && let Some(idx) = rest.find(':')
686 {
687 let raw_key = &rest[..idx];
688 let (key, append) = property_key_and_append(raw_key);
689 let val = rest[idx + 1..].trim().to_string();
690 if append {
691 properties
692 .entry(key.to_string())
693 .and_modify(|existing| {
694 if !val.is_empty() {
695 if !existing.is_empty() {
696 existing.push(' ');
697 }
698 existing.push_str(&val);
699 }
700 })
701 .or_insert(val);
702 } else {
703 properties.insert(key.to_string(), val);
704 }
705 if !property_order.iter().any(|k| k == key) {
706 property_order.push(key.to_string());
707 }
708 }
709 i += 1;
710 }
711 if i < lines.len() {
712 i += 1;
713 }
714 continue;
715 }
716 if trimmed.eq_ignore_ascii_case(":LOGBOOK:") {
717 i += 1;
718 while i < lines.len() && !lines[i].trim().eq_ignore_ascii_case(":END:") {
719 if !lines[i].trim().is_empty() {
720 logbook.push(parse_log_line(lines[i]));
721 }
722 i += 1;
723 }
724 if i < lines.len() {
725 i += 1;
726 }
727 continue;
728 }
729 let drawer_start = i;
730 i += 1;
731 while i < lines.len() && !lines[i].trim().eq_ignore_ascii_case(":END:") {
732 i += 1;
733 }
734 if i < lines.len() {
735 i += 1;
736 }
737 let mut drawer = lines[drawer_start..i].join("\n");
738 drawer.push('\n');
739 extra_drawers.push(drawer);
740 }
741
742 let body_start = i;
743 let mut body_end = body_start;
744 let mut nest = OrgScan::new();
745 while body_end < lines.len() {
746 if !nest.observe(lines[body_end]) && is_top_level_headline(lines[body_end]) {
747 break;
748 }
749 body_end += 1;
750 }
751 let body = lines[body_start..body_end]
752 .join("\n")
753 .trim_matches('\n')
754 .trim_end()
755 .to_string();
756
757 if let Some(legacy) = properties.remove(crate::model::LEGACY_TAGS_PROPERTY) {
761 property_order.retain(|key| key != crate::model::LEGACY_TAGS_PROPERTY);
762 properties
763 .entry(crate::model::TAGS_PROPERTY.to_string())
764 .or_insert(legacy);
765 }
766
767 let id = properties
768 .get("ID")
769 .cloned()
770 .ok_or_else(|| anyhow!(":ID: property missing"))?;
771
772 Ok((
773 IssueHeading {
774 id,
775 title,
776 state,
777 priority,
778 properties,
779 org_tags,
780 statistics,
781 property_order,
782 extra_drawers,
783 body,
784 logbook,
785 line_start: start + 1,
786 line_end: if body_end == 0 { 1 } else { body_end },
787 },
788 body_end,
789 ))
790}
791
792pub fn default_preamble(project: &str) -> String {
798 format!(
799 "#+TITLE: {project} issues\n#+VISSUE: {}\n#+CATEGORY: {project}\n#+FILETAGS: :issues:{project}:noexport:\n{}\n{}\n{}\n#+EXCLUDE_TAGS: noexport\n#+SELECT_TAGS: export\n#+DATE: {}\n#+DESCRIPTION: Issue tracking file for {project} specs, plans, and implementation tasks.\n#+STATUS: Active\n{}",
800 crate::org::PROTOCOL_VERSION,
801 crate::org::HOUSE_TAGS_LINES[0],
802 crate::org::HOUSE_TAGS_LINES[1],
803 crate::org::HOUSE_PRIORITIES_LINE,
804 today_inactive_bracket(),
805 TODO_HEADER
806 )
807}
808
809pub fn list_projects(layout: &Layout) -> Result<Vec<String>> {
815 let dir = layout.projects_dir();
816 if !dir.exists() {
817 return Ok(Vec::new());
818 }
819 let mut projects = Vec::new();
820 for entry in fs::read_dir(&dir).with_context(|| format!("read dir {}", dir.display()))? {
821 let entry = entry?;
822 let path = entry.path();
823 if path.is_dir()
824 && path.join("issues.org").exists()
825 && let Some(name) = path.file_name().and_then(|n| n.to_str())
826 {
827 projects.push(name.to_string());
828 }
829 }
830 projects.sort();
831 Ok(projects)
832}
833
834pub fn resolve_existing_project_case(layout: &Layout, project: &str) -> Result<String> {
841 if project.is_empty() {
842 return Ok(project.to_string());
843 }
844 if layout.project_issues_path(project).exists() {
845 return Ok(project.to_string());
846 }
847 let project_lower = project.to_lowercase();
848 let matches: Vec<String> = list_projects(layout)?
849 .into_iter()
850 .filter(|candidate| candidate.to_lowercase() == project_lower)
851 .collect();
852 match matches.as_slice() {
853 [] => Ok(project.to_string()),
854 [canonical] => Ok(canonical.clone()),
855 _ => Err(anyhow!(
856 "project {project:?} is ambiguous; case-insensitive matches: {}",
857 matches.join(", ")
858 )
859 .into()),
860 }
861}
862
863pub fn project_selected(project: &str, filter: Option<&str>) -> bool {
870 match filter {
871 None => true,
872 Some(p) => project.eq_ignore_ascii_case(p),
873 }
874}
875
876pub fn find_by_id(layout: &Layout, id: &str) -> Result<Option<(IssueHeading, PathBuf, String)>> {
882 for project in list_projects(layout)? {
883 let path = layout.project_issues_path(&project);
884 let doc = IssueDoc::parse_file(&project, &path)?;
885 for h in doc.headings {
886 if h.id == id {
887 return Ok(Some((h, path, project)));
888 }
889 }
890 }
891 Ok(None)
892}
893
894pub fn load_all(layout: &Layout) -> Result<Vec<(String, IssueHeading)>> {
900 use rayon::prelude::*;
904 let per_project: Vec<Vec<(String, IssueHeading)>> = list_projects(layout)?
905 .into_par_iter()
906 .map(|project| {
907 let path = layout.project_issues_path(&project);
908 let doc = IssueDoc::parse_file(&project, &path)?;
909 Ok(doc
910 .headings
911 .into_iter()
912 .map(|h| (project.clone(), h))
913 .collect())
914 })
915 .collect::<Result<Vec<_>>>()?;
916 Ok(per_project.into_iter().flatten().collect())
917}
918
919fn id_seed() -> u64 {
940 if let Ok(raw) = crate::process_env::var(ID_SEED_ENV)
941 && let Ok(seed) = raw.trim().parse::<u64>()
942 {
943 return seed;
944 }
945 SystemTime::now()
946 .duration_since(UNIX_EPOCH)
947 .map(|d| d.as_nanos() as u64)
948 .unwrap_or(1)
949}
950
951pub const ID_SEED_ENV: &str = "VISSUE_ID_SEED";
953
954pub fn generate_id(
988 project: &str,
989 subject: &str,
990 existing: &[String],
991 length: usize,
992) -> Result<String> {
993 let len = length.max(2);
994 let taken: std::collections::HashSet<&str> = existing.iter().map(String::as_str).collect();
995 let mut material = Vec::with_capacity(project.len() + subject.len() + 1);
1000 material.extend_from_slice(project.as_bytes());
1001 material.push(0);
1002 material.extend_from_slice(subject.as_bytes());
1003 let start = xxh3_64_with_seed(&material, id_seed());
1004 let attempts = 36usize
1007 .checked_pow(len as u32)
1008 .map(|space| space.saturating_mul(2))
1009 .unwrap_or(usize::MAX)
1010 .min(2_000_000);
1011 for counter in 0..attempts as u64 {
1012 let mut n = start.wrapping_add(counter);
1013 let mut suffix = String::new();
1014 for _ in 0..len {
1015 suffix.push(ID_ALPHABET[(n % 36) as usize] as char);
1016 n /= 36;
1017 }
1018 let id = format!("{}-{}", project, suffix);
1019 if !taken.contains(id.as_str()) {
1020 return Ok(id);
1021 }
1022 }
1023 Err(anyhow!(
1024 "no free id left for {project:?} at id_length = {len}; \
1025 raise `id_length` under [issues] in vissue.toml"
1026 )
1027 .into())
1028}
1029
1030pub fn detect_project_from_ctx(start: &Path) -> Option<String> {
1032 let mut dir = start.canonicalize().ok()?;
1033 loop {
1034 let candidate = dir.join(".project-ctx.toml");
1035 if candidate.exists()
1036 && let Ok(text) = fs::read_to_string(&candidate)
1037 && let Ok(value) = toml::from_str::<toml::Value>(&text)
1041 && let Some(name) = value
1042 .get("project")
1043 .and_then(|p| p.get("name"))
1044 .and_then(|n| n.as_str())
1045 {
1046 return Some(name.to_string());
1047 }
1048 if !dir.pop() {
1049 break;
1050 }
1051 }
1052 None
1053}
1054
1055pub fn find_org_ids(
1066 layout: &Layout,
1067 wanted: &std::collections::HashSet<String>,
1068) -> Result<std::collections::HashSet<String>> {
1069 let mut found = std::collections::HashSet::new();
1070 if wanted.is_empty() {
1071 return Ok(found);
1072 }
1073 let dir = layout.projects_dir();
1074 if !dir.exists() {
1075 return Ok(found);
1076 }
1077 for entry in walkdir::WalkDir::new(&dir)
1078 .into_iter()
1079 .filter_entry(|e| !is_skipped_dir(e))
1080 {
1081 let entry = match entry {
1082 Ok(e) => e,
1083 Err(_) => continue,
1084 };
1085 if !entry.file_type().is_file()
1086 || entry.path().extension().and_then(|s| s.to_str()) != Some("org")
1087 {
1088 continue;
1089 }
1090 let content = fs::read_to_string(entry.path())
1091 .with_context(|| format!("read {}", entry.path().display()))?;
1092 for id in org_ids(&content) {
1093 if crate::org::is_gcal_event_id(id) {
1094 continue;
1095 }
1096 if wanted.contains(id) {
1097 found.insert(id.to_string());
1098 if found.len() == wanted.len() {
1099 return Ok(found);
1100 }
1101 }
1102 }
1103 }
1104 Ok(found)
1105}
1106
1107pub fn collect_org_ids(layout: &Layout) -> Result<std::collections::HashSet<String>> {
1113 let mut ids = std::collections::HashSet::new();
1114 let dir = layout.projects_dir();
1115 if !dir.exists() {
1116 return Ok(ids);
1117 }
1118 for entry in walkdir::WalkDir::new(&dir)
1119 .into_iter()
1120 .filter_entry(|e| !is_skipped_dir(e))
1121 {
1122 let entry = match entry {
1123 Ok(e) => e,
1124 Err(_) => continue,
1125 };
1126 if !entry.file_type().is_file()
1127 || entry.path().extension().and_then(|s| s.to_str()) != Some("org")
1128 {
1129 continue;
1130 }
1131 let content = fs::read_to_string(entry.path())
1132 .with_context(|| format!("read {}", entry.path().display()))?;
1133 for id in org_ids(&content) {
1134 if !crate::org::is_gcal_event_id(id) {
1135 ids.insert(id.to_string());
1136 }
1137 }
1138 }
1139 Ok(ids)
1140}
1141
1142fn is_skipped_dir(entry: &walkdir::DirEntry) -> bool {
1143 if !entry.file_type().is_dir() {
1144 return false;
1145 }
1146 let name = entry.file_name().to_string_lossy();
1147 matches!(
1148 name.as_ref(),
1149 "node_modules" | "target" | ".git" | ".cache" | "build"
1150 )
1151}
1152
1153fn org_id_property_value(line: &str) -> Option<&str> {
1154 let value = line.trim_start().strip_prefix(":ID:")?.trim();
1155 if value.is_empty() { None } else { Some(value) }
1156}
1157
1158pub(crate) fn org_ids(content: &str) -> impl Iterator<Item = &str> {
1162 let mut at_drawer_site = true;
1164 let mut in_drawer = false;
1165 let mut drawer_is_properties = false;
1166 let mut under_headline = false;
1169 let mut nest = OrgScan::new();
1170
1171 content.lines().filter_map(move |line| {
1172 let trimmed = line.trim();
1173
1174 if in_drawer {
1175 if trimmed.eq_ignore_ascii_case(":END:") {
1176 in_drawer = false;
1177 drawer_is_properties = false;
1178 at_drawer_site = true;
1181 return None;
1182 }
1183 if drawer_is_properties {
1184 return org_id_property_value(line);
1185 }
1186 return None;
1187 }
1188
1189 if nest.observe(line) {
1192 at_drawer_site = false;
1193 under_headline = false;
1194 return None;
1195 }
1196
1197 if is_headline(line) {
1198 at_drawer_site = true;
1199 under_headline = true;
1200 return None;
1201 }
1202 let planning_may_start_here = under_headline;
1203 under_headline = false;
1204
1205 if trimmed.is_empty() || trimmed.starts_with("#+") {
1208 return None;
1209 }
1210 if at_drawer_site {
1211 if opens_a_drawer(trimmed) {
1212 in_drawer = true;
1213 drawer_is_properties = trimmed.eq_ignore_ascii_case(":PROPERTIES:");
1214 return None;
1215 }
1216 if planning_may_start_here && is_planning_line(trimmed) {
1217 return None;
1218 }
1219 }
1220 at_drawer_site = false;
1222 None
1223 })
1224}
1225
1226#[cfg(test)]
1227mod tests {
1228 use super::*;
1229 use crate::config::DEFAULT_PREFIX;
1230
1231 fn sample_heading() -> IssueHeading {
1232 let mut props = BTreeMap::new();
1233 props.insert("ID".into(), "sample-abc1".into());
1234 props.insert("CREATED".into(), "[2026-04-25 Sat]".into());
1235 props.insert("TYPE".into(), "feature".into());
1236 IssueHeading {
1237 id: "sample-abc1".into(),
1238 title: "Add a thing".into(),
1239 state: "TODO".into(),
1240 priority: 'A',
1241 properties: props,
1242 org_tags: Vec::new(),
1243 statistics: None,
1244 property_order: vec!["ID".into(), "CREATED".into(), "TYPE".into()],
1245 extra_drawers: Vec::new(),
1246 body: "Some body lines.\nWith multiple lines.".into(),
1247 logbook: Vec::new(),
1248 line_start: 4,
1249 line_end: 12,
1250 }
1251 }
1252
1253 #[test]
1254 fn render_then_parse_preserves_the_heading() {
1255 let mut content = String::from("#+TITLE: sample issues\n");
1256 content.push_str(TODO_HEADER);
1257 content.push_str("\n\n");
1258 content.push_str(&sample_heading().render());
1259 let parsed = IssueDoc::parse("sample", PathBuf::from("/tmp/x.org"), &content).unwrap();
1260 let h = &parsed.headings[0];
1261 let original = sample_heading();
1262 assert_eq!(h.id, original.id);
1263 assert_eq!(h.title, original.title);
1264 assert_eq!(h.state, original.state);
1265 assert_eq!(h.priority, original.priority);
1266 assert_eq!(h.body, original.body);
1267 assert_eq!(
1268 crate::props::get(&h.properties, crate::props::TYPE),
1269 crate::props::get(&original.properties, crate::props::TYPE)
1270 );
1271 }
1272
1273 #[test]
1274 fn heading_without_a_priority_cookie_defaults_to_c() {
1275 let content =
1276 "#+TITLE: x issues\n\n* TODO Just a title\n:PROPERTIES:\n:ID: x-aaaa\n:END:\n";
1277 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1278 assert_eq!(doc.headings[0].priority, 'C');
1279 assert_eq!(doc.headings[0].title, "Just a title");
1280 }
1281
1282 #[test]
1283 fn a_multibyte_priority_cookie_parses_instead_of_panicking() {
1284 let content = "#+TITLE: x issues\n\n* TODO [#\u{2192}] Hand edited\n:PROPERTIES:\n:ID: x-aaaa\n:END:\n";
1285 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1286 assert_eq!(doc.headings[0].priority, '\u{2192}');
1287 assert_eq!(doc.headings[0].title, "Hand edited");
1288 }
1289
1290 #[test]
1291 fn a_title_opening_with_a_bracket_keeps_its_text() {
1292 let content = "#+TITLE: x issues\n\n* TODO [#not a cookie] stays\n:PROPERTIES:\n:ID: x-bbbb\n:END:\n";
1293 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1294 assert_eq!(doc.headings[0].priority, 'C');
1295 assert_eq!(doc.headings[0].title, "[#not a cookie] stays");
1296 }
1297
1298 #[test]
1299 fn an_org_planning_line_parses_instead_of_hiding_the_drawer() {
1300 let content = "#+TITLE: x issues\n\n* DONE [#A] Ship it\nCLOSED: [2026-08-14 Fri 03:33] SCHEDULED: <2026-09-05 Sat> DEADLINE: <2026-09-01 Tue>\n:PROPERTIES:\n:ID: x-aaaa\n:END:\n\nBody stays body.\n";
1304 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1305 let h = &doc.headings[0];
1306 assert_eq!(h.id, "x-aaaa");
1307 assert_eq!(h.deadline(), Some("<2026-09-01 Tue>"));
1308 assert_eq!(h.scheduled(), Some("<2026-09-05 Sat>"));
1309 assert_eq!(
1310 h.properties.get("CLOSED").map(String::as_str),
1311 Some("[2026-08-14 Fri 03:33]")
1312 );
1313 assert_eq!(h.body, "Body stays body.");
1314 }
1315
1316 #[test]
1317 fn a_planning_line_round_trips_in_orgs_own_order() {
1318 let content = "#+TITLE: x issues\n\n* DONE [#A] Ship it\nCLOSED: [2026-08-14 Fri 03:33] SCHEDULED: <2026-09-05 Sat> DEADLINE: <2026-09-01 Tue>\n:PROPERTIES:\n:ID: x-aaaa\n:END:\n";
1319 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1320 let rendered = doc.headings[0].render();
1321 assert!(
1322 rendered.contains(
1323 "\nCLOSED: [2026-08-14 Fri 03:33] SCHEDULED: <2026-09-05 Sat> DEADLINE: <2026-09-01 Tue>\n"
1324 ),
1325 "{rendered}"
1326 );
1327 assert!(!rendered.contains(":DEADLINE:"), "{rendered}");
1328 }
1329
1330 #[test]
1331 fn a_legacy_date_property_is_promoted_to_a_planning_line() {
1332 let content = "#+TITLE: x issues\n\n* TODO [#A] Ship it\n:PROPERTIES:\n:ID: x-aaaa\n:DEADLINE: <2026-09-01 Tue>\n:END:\n";
1335 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1336 assert_eq!(doc.headings[0].deadline(), Some("<2026-09-01 Tue>"));
1337 let rendered = doc.headings[0].render();
1338 assert!(
1339 rendered.contains("\nDEADLINE: <2026-09-01 Tue>\n"),
1340 "{rendered}"
1341 );
1342 assert!(!rendered.contains(":DEADLINE:"), "{rendered}");
1343 }
1344
1345 #[test]
1346 fn a_line_that_only_looks_like_planning_is_left_as_body() {
1347 let content = "#+TITLE: x issues\n\n* TODO [#A] Ship it\n:PROPERTIES:\n:ID: x-aaaa\n:END:\n\nDEADLINE: is discussed in the design note.\n";
1348 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1349 assert_eq!(doc.headings[0].deadline(), None);
1350 assert_eq!(
1351 doc.headings[0].body,
1352 "DEADLINE: is discussed in the design note."
1353 );
1354 }
1355
1356 #[test]
1357 fn a_legacy_tags_property_moves_to_the_name_org_leaves_alone() {
1358 let content = "#+TITLE: x issues\n\n* TODO [#A] Ship it\n:PROPERTIES:\n:ID: x-aaaa\n:TAGS: needs-review,perf\n:END:\n";
1361 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1362 let h = &doc.headings[0];
1363 assert_eq!(h.tags(), vec!["needs-review", "perf"]);
1364 let rendered = h.render();
1365 assert!(
1366 rendered.contains(":VISSUE_TAGS: needs-review"),
1367 "{rendered}"
1368 );
1369 assert!(rendered.contains(":perf:"), "{rendered}");
1370 assert!(!rendered.contains(":TAGS:"), "{rendered}");
1371 }
1372
1373 #[test]
1374 fn bodies_end_at_the_next_heading() {
1375 let content = "#+TITLE: x issues\n\n* TODO [#A] First\n:PROPERTIES:\n:ID: x-1111\n:END:\n\nFirst body.\n\n* DONE [#C] Second\n:PROPERTIES:\n:ID: x-2222\n:END:\n\nSecond body.\nMulti.\n";
1376 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1377 assert_eq!(doc.headings.len(), 2);
1378 assert_eq!(doc.headings[0].body, "First body.");
1379 assert_eq!(doc.headings[1].body, "Second body.\nMulti.");
1380 }
1381
1382 #[test]
1383 fn logbook_survives_a_document_round_trip() {
1384 let mut h = sample_heading();
1385 h.logbook = vec![LogEntry {
1386 timestamp: "[2026-04-26 Sun 09:15]".into(),
1387 from_state: Some("TODO".into()),
1388 to_state: Some("STARTED".into()),
1389 note: None,
1390 raw: None,
1391 }];
1392 let mut content = String::from("#+TITLE: sample issues\n\n");
1393 content.push_str(&h.render());
1394 let parsed = IssueDoc::parse("sample", PathBuf::from("/tmp/x.org"), &content).unwrap();
1395 assert_eq!(parsed.headings[0].logbook.len(), 1);
1396 assert_eq!(
1397 parsed.headings[0].logbook[0].from_state.as_deref(),
1398 Some("TODO")
1399 );
1400 }
1401
1402 #[test]
1403 fn write_then_reread_finds_the_heading() {
1404 let dir = tempfile::tempdir().unwrap();
1405 let path = dir.path().join("Software/sample/issues.org");
1406 IssueDoc {
1407 keywords: crate::org::TodoSequence::house(),
1408 project: "sample".into(),
1409 path: path.clone(),
1410 preamble: default_preamble("sample"),
1411 tag_settings: tag_settings_from_preamble(&default_preamble("sample")),
1412 headings: vec![sample_heading()],
1413 after: vec![String::new()],
1414 }
1415 .write()
1416 .unwrap();
1417 let parsed = IssueDoc::parse_file("sample", &path).unwrap();
1418 assert_eq!(parsed.headings[0].id, "sample-abc1");
1419 }
1420
1421 #[test]
1422 fn write_preserves_the_existing_preamble_and_property_order() {
1423 let dir = tempfile::tempdir().unwrap();
1424 let path = dir.path().join("Software/sample/issues.org");
1425 fs::create_dir_all(path.parent().unwrap()).unwrap();
1426 fs::write(
1427 &path,
1428 "#+TITLE: sample issues\n#+FILETAGS: :issues:sample:\n#+STATUS: Active\n#+TODO: TODO STARTED BLOCKED | DONE CANCELLED\n\n* TODO [#A] Existing issue\n:PROPERTIES:\n:ID: sample-abc1\n:CREATED: [2026-04-26 Sun]\n:TYPE: spec\n:PARENT: sample-root\n:END:\n",
1429 )
1430 .unwrap();
1431
1432 let mut doc = IssueDoc::parse_file("sample", &path).unwrap();
1433 doc.headings[0].priority = 'B';
1434 doc.write().unwrap();
1435 let written = fs::read_to_string(&path).unwrap();
1436
1437 assert!(written.contains("#+FILETAGS: :issues:sample:"), "{written}");
1438 assert!(written.contains("#+CATEGORY: sample"), "{written}");
1439 assert!(written.contains("#+STATUS: Active"), "{written}");
1440 assert!(
1441 written.find(":TYPE:").unwrap() < written.find(":PARENT:").unwrap(),
1442 "{written}"
1443 );
1444 }
1445
1446 #[test]
1447 fn a_gcal_event_heading_is_not_an_issue() {
1448 let dir = tempfile::tempdir().unwrap();
1449 let path = dir.path().join("Software/sample/issues.org");
1450 fs::create_dir_all(path.parent().unwrap()).unwrap();
1451 fs::write(
1452 &path,
1453 "#+TITLE: sample issues\n#+TODO: TODO STARTED BLOCKED | DONE CANCELLED\n\n* TODO [#A] Calendar dump\n:PROPERTIES:\n:ID: abc123/primary@group.calendar.google.com\n:END:\n\n* TODO [#B] Real work\n:PROPERTIES:\n:ID: sample-aaaa\n:END:\n",
1454 )
1455 .unwrap();
1456 let doc = IssueDoc::parse_file("sample", &path).unwrap();
1457 assert_eq!(
1458 doc.headings.len(),
1459 1,
1460 "{:?}",
1461 doc.headings.iter().map(|h| &h.id).collect::<Vec<_>>()
1462 );
1463 assert_eq!(doc.headings[0].id, "sample-aaaa");
1464 let written = doc.render_string();
1465 assert!(
1466 written.contains("abc123/primary@group.calendar.google.com"),
1467 "{written}"
1468 );
1469 }
1470
1471 #[test]
1472 fn setupfile_todo_keywords_make_an_issue() {
1473 let dir = tempfile::tempdir().unwrap();
1474 let setup = dir.path().join("house.org");
1475 fs::write(&setup, "#+TODO: TODO HOLD | DONE\n").unwrap();
1476 let path = dir.path().join("Software/sample/issues.org");
1477 fs::create_dir_all(path.parent().unwrap()).unwrap();
1478 fs::write(
1479 &path,
1480 format!(
1481 "#+TITLE: sample issues\n#+SETUPFILE: {}\n\n* HOLD [#C] Parked\n:PROPERTIES:\n:ID: sample-hold\n:END:\n",
1482 setup.display()
1483 ),
1484 )
1485 .unwrap();
1486 let doc = IssueDoc::parse_file("sample", &path).unwrap();
1487 assert_eq!(doc.headings.len(), 1);
1488 assert_eq!(doc.headings[0].state, "HOLD");
1489 assert_eq!(doc.priority_spec().default, 'C');
1490 }
1491
1492 #[test]
1493 fn file_priorities_set_the_missing_cookie() {
1494 let dir = tempfile::tempdir().unwrap();
1495 let path = dir.path().join("Software/sample/issues.org");
1496 fs::create_dir_all(path.parent().unwrap()).unwrap();
1497 fs::write(
1498 &path,
1499 "#+TITLE: sample issues\n#+PRIORITIES: A D B\n#+TODO: TODO | DONE\n\n* TODO No cookie\n:PROPERTIES:\n:ID: sample-none\n:END:\n",
1500 )
1501 .unwrap();
1502 let doc = IssueDoc::parse_file("sample", &path).unwrap();
1503 assert_eq!(doc.headings[0].priority, 'B');
1504 assert_eq!(doc.priority_spec().lowest, 'D');
1505 }
1506
1507 #[test]
1508 fn an_empty_document_writes_the_house_preamble() {
1509 let dir = tempfile::tempdir().unwrap();
1510 let path = dir.path().join("Software/sample/issues.org");
1511 IssueDoc::empty("sample", path.clone()).write().unwrap();
1512 let written = fs::read_to_string(&path).unwrap();
1513 for expected in [
1514 "#+TITLE: sample issues",
1515 "#+VISSUE: 1",
1516 "#+CATEGORY: sample",
1519 "#+FILETAGS: :issues:sample:noexport:",
1520 "#+TAGS: { bug(b) feature(f) task(t) chore(c) plan(p) }",
1521 "#+PRIORITIES: A C C",
1522 "#+EXCLUDE_TAGS: noexport",
1523 "#+SELECT_TAGS: export",
1524 "#+DATE:",
1525 "#+STATUS: Active",
1526 TODO_HEADER,
1527 ] {
1528 assert!(written.contains(expected), "missing {expected}: {written}");
1529 }
1530 }
1531
1532 #[test]
1533 fn generated_ids_are_unique_and_sized() {
1534 let existing = vec!["p-aaaa".to_string()];
1535 let id = generate_id("p", "a subject", &existing, 4).unwrap();
1536 assert!(id.starts_with("p-"));
1537 assert!(!existing.contains(&id));
1538 assert_eq!(id.len(), 1 + 1 + 4);
1539 assert_eq!(generate_id("q", "t", &[], 6).unwrap().len(), 1 + 1 + 6);
1540 }
1541
1542 #[test]
1543 fn a_full_suffix_space_is_an_error_and_not_a_panic() {
1544 let mut existing = Vec::new();
1547 for a in ID_ALPHABET {
1548 for b in ID_ALPHABET {
1549 existing.push(format!("p-{}{}", *a as char, *b as char));
1550 }
1551 }
1552 let err = generate_id("p", "t", &existing, 2).unwrap_err();
1553 assert!(err.to_string().contains("id_length"), "{err}");
1554 existing.pop();
1556 assert!(generate_id("p", "t", &existing, 2).is_ok());
1557 }
1558
1559 #[test]
1560 fn projects_are_discovered_under_the_configured_prefix() {
1561 let dir = tempfile::tempdir().unwrap();
1562 let layout = Layout::new(dir.path(), "tracker");
1563 for project in ["beta", "alpha"] {
1564 IssueDoc::empty(project, layout.project_issues_path(project))
1565 .write()
1566 .unwrap();
1567 }
1568 assert_eq!(list_projects(&layout).unwrap(), vec!["alpha", "beta"]);
1569 assert!(
1570 list_projects(&Layout::new(dir.path(), DEFAULT_PREFIX))
1571 .unwrap()
1572 .is_empty()
1573 );
1574 }
1575
1576 #[test]
1577 fn project_case_resolves_to_the_directory_on_disk() {
1578 let dir = tempfile::tempdir().unwrap();
1579 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
1580 IssueDoc::empty("MixedCase", layout.project_issues_path("MixedCase"))
1581 .write()
1582 .unwrap();
1583 assert_eq!(
1584 resolve_existing_project_case(&layout, "mixedcase").unwrap(),
1585 "MixedCase"
1586 );
1587 assert_eq!(
1588 resolve_existing_project_case(&layout, "brand-new").unwrap(),
1589 "brand-new"
1590 );
1591 }
1592
1593 #[test]
1594 fn project_context_file_is_found_by_walking_up() {
1595 let dir = tempfile::tempdir().unwrap();
1596 let nested = dir.path().join("a/b/c");
1597 fs::create_dir_all(&nested).unwrap();
1598 fs::write(
1599 dir.path().join(".project-ctx.toml"),
1600 "[project]\nname = \"demoproj\"\n",
1601 )
1602 .unwrap();
1603 assert_eq!(
1604 detect_project_from_ctx(&nested).as_deref(),
1605 Some("demoproj")
1606 );
1607 let empty = tempfile::tempdir().unwrap();
1608 assert!(detect_project_from_ctx(empty.path()).is_none());
1609 }
1610
1611 fn ids(content: &str) -> Vec<&str> {
1612 org_ids(content).collect()
1613 }
1614
1615 #[test]
1616 fn a_drawer_under_a_headline_defines_an_id() {
1617 assert_eq!(
1618 ids("* TODO [#B] a title\n:PROPERTIES:\n:ID: atlas-1a2b\n:END:\n"),
1619 ["atlas-1a2b"]
1620 );
1621 }
1622
1623 #[test]
1624 fn a_drawer_under_the_planning_line_defines_an_id() {
1625 let text = concat!(
1626 "* TODO a title\n",
1627 "DEADLINE: <2026-05-15 Fri>\n",
1628 ":PROPERTIES:\n",
1629 ":ID: atlas-1a2b\n",
1630 ":END:\n",
1631 );
1632 assert_eq!(ids(text), ["atlas-1a2b"]);
1633 }
1634
1635 #[test]
1636 fn a_logbook_beside_the_properties_does_not_hide_it() {
1637 let text = concat!(
1638 "* TODO a title\n",
1639 ":LOGBOOK:\n",
1640 "- claimed by worker-1\n",
1641 ":END:\n",
1642 ":PROPERTIES:\n",
1643 ":ID: atlas-1a2b\n",
1644 ":END:\n",
1645 );
1646 assert_eq!(ids(text), ["atlas-1a2b"]);
1647 }
1648
1649 #[test]
1650 fn an_id_quoted_in_a_body_defines_nothing() {
1651 let text = concat!(
1653 "* TODO a title\n",
1654 ":PROPERTIES:\n",
1655 ":ID: atlas-1a2b\n",
1656 ":END:\n",
1657 "\n",
1658 "The heading I was handed reads:\n",
1659 ":PROPERTIES:\n",
1660 ":ID: ghost-9999\n",
1661 ":END:\n",
1662 );
1663 assert_eq!(
1664 ids(text),
1665 ["atlas-1a2b"],
1666 "a report that quotes an id defined it"
1667 );
1668 }
1669
1670 #[test]
1671 fn a_bare_id_line_in_a_body_defines_nothing() {
1672 let text = concat!(
1673 "* TODO a title\n",
1674 ":PROPERTIES:\n",
1675 ":ID: atlas-1a2b\n",
1676 ":END:\n",
1677 "\n",
1678 "Compare with :ID: ghost-9999 in the other file.\n",
1679 ":ID: ghost-8888\n",
1680 );
1681 assert_eq!(ids(text), ["atlas-1a2b"]);
1682 }
1683
1684 #[test]
1685 fn a_file_level_drawer_defines_an_id() {
1686 let text = concat!(
1688 "#+TITLE: atlas issues\n",
1689 "\n",
1690 ":PROPERTIES:\n",
1691 ":ID: the-file-itself\n",
1692 ":END:\n",
1693 "\n",
1694 "* TODO a title\n",
1695 ":PROPERTIES:\n",
1696 ":ID: atlas-1a2b\n",
1697 ":END:\n",
1698 );
1699 assert_eq!(ids(text), ["the-file-itself", "atlas-1a2b"]);
1700 }
1701
1702 #[test]
1703 fn every_headline_depth_opens_a_drawer_site() {
1704 let text = concat!(
1705 "* TODO a title\n",
1706 ":PROPERTIES:\n",
1707 ":ID: atlas-1a2b\n",
1708 ":END:\n",
1709 "** A sub-heading someone wrote by hand\n",
1710 ":PROPERTIES:\n",
1711 ":ID: atlas-3c4d\n",
1712 ":END:\n",
1713 );
1714 assert_eq!(ids(text), ["atlas-1a2b", "atlas-3c4d"]);
1715 }
1716
1717 #[test]
1718 fn a_planning_keyword_needs_its_colon() {
1719 assert!(is_planning_line("DEADLINE: <2026-05-15 Fri>"));
1720 assert!(is_planning_line("CLOSED: [2026-05-15 Fri]"));
1721 assert!(!is_planning_line("DEADLINES slipped again"));
1723 assert!(!is_planning_line("SCHEDULED work for the week"));
1724 }
1725
1726 #[test]
1727 fn prose_that_opens_like_a_planning_line_still_ends_the_drawer_site() {
1728 let text = concat!(
1732 "* TODO a title\n",
1733 ":PROPERTIES:\n",
1734 ":ID: atlas-1a2b\n",
1735 ":END:\n",
1736 "\n",
1737 "DEADLINE: is discussed in the design note.\n",
1738 ":PROPERTIES:\n",
1739 ":ID: ghost-9999\n",
1740 ":END:\n",
1741 );
1742 assert_eq!(ids(text), ["atlas-1a2b"]);
1743 }
1744
1745 #[test]
1746 fn a_headline_needs_a_space_after_its_stars() {
1747 assert!(is_headline("* TODO a title"));
1748 assert!(is_headline("*** deeper"));
1749 assert!(!is_headline("**bold** at the start of a line"));
1750 assert!(!is_headline("not a headline"));
1751 }
1752
1753 #[test]
1754 fn a_source_block_does_not_split_an_issue() {
1755 let content = concat!(
1756 "#+TITLE: x issues\n\n",
1757 "* TODO [#A] Real issue\n",
1758 ":PROPERTIES:\n",
1759 ":ID: x-aaaa\n",
1760 ":END:\n\n",
1761 "Quoted tracker:\n",
1762 "#+BEGIN_SRC org\n",
1763 "* TODO quoted\n",
1764 ":PROPERTIES:\n",
1765 ":ID: ghost-9999\n",
1766 ":END:\n",
1767 "#+END_SRC\n\n",
1768 "Still the same issue.\n",
1769 );
1770 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1771 assert_eq!(
1772 doc.headings.len(),
1773 1,
1774 "{:?}",
1775 doc.headings.iter().map(|h| &h.id).collect::<Vec<_>>()
1776 );
1777 assert_eq!(doc.headings[0].id, "x-aaaa");
1778 assert!(
1779 doc.headings[0].body.contains("* TODO quoted"),
1780 "{}",
1781 doc.headings[0].body
1782 );
1783 assert!(doc.headings[0].body.contains("Still the same issue."));
1784 }
1785
1786 #[test]
1787 fn org_ids_ignore_a_drawer_inside_a_block() {
1788 let text = concat!(
1789 "#+BEGIN_SRC org\n",
1790 "* TODO quoted\n",
1791 ":PROPERTIES:\n",
1792 ":ID: ghost-9999\n",
1793 ":END:\n",
1794 "#+END_SRC\n",
1795 "* TODO a title\n",
1796 ":PROPERTIES:\n",
1797 ":ID: atlas-1a2b\n",
1798 ":END:\n",
1799 );
1800 assert_eq!(ids(text), ["atlas-1a2b"]);
1801 }
1802
1803 #[test]
1804 fn a_timestamp_range_on_the_planning_line_parses() {
1805 let content = concat!(
1806 "#+TITLE: x issues\n\n",
1807 "* TODO [#A] Sprint\n",
1808 "SCHEDULED: <2026-09-01 Tue>--<2026-09-08 Tue> DEADLINE: <2026-09-15 Mon +1w>\n",
1809 ":PROPERTIES:\n",
1810 ":ID: x-aaaa\n",
1811 ":END:\n",
1812 );
1813 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1814 assert_eq!(doc.headings[0].id, "x-aaaa");
1815 assert_eq!(
1816 doc.headings[0].scheduled(),
1817 Some("<2026-09-01 Tue>--<2026-09-08 Tue>")
1818 );
1819 assert_eq!(doc.headings[0].deadline(), Some("<2026-09-15 Mon +1w>"));
1820 }
1821
1822 #[test]
1823 fn a_repeater_and_warning_on_the_planning_line_parse() {
1824 let content = concat!(
1825 "#+TITLE: x issues\n\n",
1826 "* TODO [#A] Weekly\n",
1827 "DEADLINE: <2026-09-01 Tue +1w -2d>\n",
1828 ":PROPERTIES:\n",
1829 ":ID: x-aaaa\n",
1830 ":END:\n",
1831 );
1832 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1833 assert_eq!(doc.headings[0].deadline(), Some("<2026-09-01 Tue +1w -2d>"));
1834 let rendered = doc.headings[0].render();
1835 assert!(
1836 rendered.contains("DEADLINE: <2026-09-01 Tue +1w -2d>"),
1837 "{rendered}"
1838 );
1839 }
1840
1841 #[test]
1842 fn a_logbook_before_properties_still_parses() {
1843 let content = concat!(
1844 "#+TITLE: x issues\n\n",
1845 "* TODO [#A] Clocked\n",
1846 ":LOGBOOK:\n",
1847 "CLOCK: [2026-08-18 Tue 10:00]--[2026-08-18 Tue 11:00] => 1:00\n",
1848 ":END:\n",
1849 ":PROPERTIES:\n",
1850 ":ID: x-aaaa\n",
1851 ":END:\n",
1852 );
1853 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1854 assert_eq!(doc.headings[0].id, "x-aaaa");
1855 assert_eq!(doc.headings[0].logbook.len(), 1);
1856 assert!(doc.headings[0].logbook[0].raw.is_some());
1857 }
1858
1859 #[test]
1860 fn other_drawers_at_the_drawer_site_do_not_hide_the_id() {
1861 let content = concat!(
1862 "#+TITLE: x issues\n\n",
1863 "* TODO [#A] Notes drawer\n",
1864 ":NOTES:\n",
1865 "hand written\n",
1866 ":END:\n",
1867 ":PROPERTIES:\n",
1868 ":ID: x-aaaa\n",
1869 ":END:\n\n",
1870 "Body stays.\n",
1871 );
1872 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1873 assert_eq!(doc.headings[0].id, "x-aaaa");
1874 assert_eq!(doc.headings[0].body, "Body stays.");
1875 let rendered = doc.headings[0].render();
1876 assert!(rendered.contains(":NOTES:"), "{rendered}");
1877 assert!(rendered.contains("hand written"), "{rendered}");
1878 }
1879
1880 #[test]
1881 fn a_comment_heading_is_not_an_issue() {
1882 let content = concat!(
1883 "#+TITLE: x issues\n\n",
1884 "* COMMENT Archived discussion\n",
1885 ":PROPERTIES:\n",
1886 ":ID: ghost-old\n",
1887 ":END:\n\n",
1888 "* TODO [#A] Live\n",
1889 ":PROPERTIES:\n",
1890 ":ID: x-aaaa\n",
1891 ":END:\n",
1892 );
1893 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1894 assert_eq!(doc.headings.len(), 1);
1895 assert_eq!(doc.headings[0].id, "x-aaaa");
1896 assert!(doc.preamble.contains("COMMENT Archived"));
1897 }
1898
1899 #[test]
1900 fn a_section_heading_round_trips() {
1901 let content = concat!(
1902 "#+TITLE: x issues\n\n",
1903 "* TODO [#A] First\n",
1904 ":PROPERTIES:\n",
1905 ":ID: x-aaaa\n",
1906 ":END:\n\n",
1907 "First body.\n\n",
1908 "* Notes\n",
1909 "Hand-written section.\n\n",
1910 "* TODO [#B] Second\n",
1911 ":PROPERTIES:\n",
1912 ":ID: x-bbbb\n",
1913 ":END:\n",
1914 );
1915 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1916 assert_eq!(doc.headings.len(), 2);
1917 assert_eq!(doc.headings[0].body, "First body.");
1918 assert!(doc.after[0].contains("* Notes"));
1919 assert!(doc.after[0].contains("Hand-written section."));
1920 let rendered = doc.headings[0].render();
1921 let mut file = String::from("#+TITLE: x issues\n\n");
1922 file.push_str(&rendered);
1923 file.push('\n');
1924 file.push_str(&doc.after[0]);
1925 file.push_str(&doc.headings[1].render());
1926 let again = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), &file).unwrap();
1927 assert_eq!(again.headings.len(), 2);
1928 assert!(again.after[0].contains("* Notes"));
1929 }
1930
1931 #[test]
1932 fn a_file_local_todo_keyword_is_an_issue() {
1933 let content = concat!(
1934 "#+TITLE: x issues\n",
1935 "#+TODO: TODO WAIT | DONE\n\n",
1936 "* WAIT [#B] Parked\n",
1937 ":PROPERTIES:\n",
1938 ":ID: x-aaaa\n",
1939 ":END:\n",
1940 );
1941 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1942 assert_eq!(doc.headings.len(), 1);
1943 assert_eq!(doc.headings[0].state, "WAIT");
1944 assert_eq!(doc.headings[0].id, "x-aaaa");
1945 }
1946
1947 #[test]
1948 fn a_statistics_cookie_is_not_the_title() {
1949 let content = concat!(
1950 "#+TITLE: x issues\n\n",
1951 "* TODO [#A] Break it down [2/5] :plan:\n",
1952 ":PROPERTIES:\n",
1953 ":ID: x-aaaa\n",
1954 ":END:\n",
1955 );
1956 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1957 assert_eq!(doc.headings[0].title, "Break it down");
1958 assert_eq!(doc.headings[0].statistics.as_deref(), Some("[2/5]"));
1959 assert_eq!(doc.headings[0].org_tags, vec!["plan"]);
1960 let line = doc.headings[0].render().lines().next().unwrap().to_string();
1961 assert!(line.contains("[2/5]"), "{line:?}");
1962 assert!(line.contains(":plan:"), "{line:?}");
1963 }
1964
1965 #[test]
1966 fn a_property_plus_appends() {
1967 let content = concat!(
1968 "#+TITLE: x issues\n\n",
1969 "* TODO [#A] Blocked\n",
1970 ":PROPERTIES:\n",
1971 ":ID: x-aaaa\n",
1972 ":BLOCKED_BY: x-bbbb\n",
1973 ":BLOCKED_BY+: x-cccc\n",
1974 ":END:\n",
1975 );
1976 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1977 assert_eq!(doc.headings[0].blocked_by(), vec!["x-bbbb", "x-cccc"]);
1978 }
1979
1980 #[test]
1981 fn babel_results_do_not_split_an_issue_or_define_an_id() {
1982 let content = concat!(
1983 "#+TITLE: x issues\n\n",
1984 "* TODO [#A] Real issue\n",
1985 ":PROPERTIES:\n",
1986 ":ID: x-aaaa\n",
1987 ":END:\n\n",
1988 "#+NAME: dump\n",
1989 "#+HEADER: :results raw\n",
1990 "#+BEGIN_SRC python :results raw\n",
1991 "print('* TODO dumped')\n",
1992 "#+END_SRC\n\n",
1993 "#+RESULTS:\n",
1994 "* TODO dumped\n",
1995 ":PROPERTIES:\n",
1996 ":ID: ghost-9999\n",
1997 ":END:\n\n",
1998 "Still the same issue.\n\n",
1999 "* TODO [#B] Next\n",
2000 ":PROPERTIES:\n",
2001 ":ID: x-bbbb\n",
2002 ":END:\n",
2003 );
2004 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
2005 assert_eq!(
2006 doc.headings
2007 .iter()
2008 .map(|h| h.id.as_str())
2009 .collect::<Vec<_>>(),
2010 ["x-aaaa", "x-bbbb"]
2011 );
2012 assert!(
2013 doc.headings[0].body.contains("#+RESULTS:"),
2014 "{}",
2015 doc.headings[0].body
2016 );
2017 assert!(
2018 doc.headings[0].body.contains("* TODO dumped"),
2019 "{}",
2020 doc.headings[0].body
2021 );
2022 assert!(doc.headings[0].body.contains("Still the same issue."));
2023 assert_eq!(ids(content), ["x-aaaa", "x-bbbb"]);
2024 }
2025
2026 #[test]
2027 fn a_babel_call_with_results_drawer_stays_in_the_body() {
2028 let content = concat!(
2029 "#+TITLE: x issues\n\n",
2030 "* TODO [#A] Calls a named block\n",
2031 ":PROPERTIES:\n",
2032 ":ID: x-aaaa\n",
2033 ":END:\n\n",
2034 "#+CALL: plot(x=1) :results drawer\n",
2035 "#+RESULTS:\n",
2036 ":RESULTS:\n",
2037 "* TODO not an issue\n",
2038 ":END:\n",
2039 );
2040 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
2041 assert_eq!(doc.headings.len(), 1);
2042 assert!(doc.headings[0].body.contains("#+CALL: plot"));
2043 assert!(doc.headings[0].body.contains("* TODO not an issue"));
2044 assert_eq!(ids(content), ["x-aaaa"]);
2045 }
2046 #[test]
2048 fn the_same_project_subject_and_seed_mint_the_same_id() {
2049 crate::process_env::override_var(ID_SEED_ENV, Some("99"));
2050 let a = generate_id("proj", "write the thing", &[], 4).unwrap();
2051 let b = generate_id("proj", "write the thing", &[], 4).unwrap();
2052 assert_eq!(a, b, "the same inputs minted two different ids");
2053
2054 let c = generate_id("proj", "a different thing", &[], 4).unwrap();
2057 assert_ne!(a, c);
2058
2059 let d = generate_id("other", "write the thing", &[], 4).unwrap();
2061 assert_ne!(a, d);
2062 crate::process_env::clear_override(ID_SEED_ENV);
2063 }
2064
2065 #[test]
2068 fn two_issues_with_one_subject_do_not_share_an_id() {
2069 crate::process_env::override_var(ID_SEED_ENV, Some("7"));
2070 let first = generate_id("proj", "same title", &[], 4).unwrap();
2071 let second = generate_id("proj", "same title", std::slice::from_ref(&first), 4).unwrap();
2072 assert_ne!(first, second);
2073 crate::process_env::clear_override(ID_SEED_ENV);
2074 }
2075
2076 #[test]
2078 fn a_different_seed_mints_a_different_id() {
2079 crate::process_env::override_var(ID_SEED_ENV, Some("1"));
2080 let one = generate_id("proj", "subject", &[], 4).unwrap();
2081 crate::process_env::override_var(ID_SEED_ENV, Some("2"));
2082 let two = generate_id("proj", "subject", &[], 4).unwrap();
2083 crate::process_env::clear_override(ID_SEED_ENV);
2084 assert_ne!(one, two);
2085 }
2086}