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();
164 let mut keys: Vec<PathBuf> = Vec::new();
165 let mut ordered: Vec<PathBuf> = paths.iter().map(|p| (*p).to_path_buf()).collect();
166 ordered.sort();
167 for path in ordered {
168 let identity = path.canonicalize().unwrap_or_else(|_| path.clone());
169 if seen.contains(&identity) {
170 continue;
171 }
172 seen.push(identity);
173 keys.push(path);
174 }
175 let mutexes: Vec<Arc<Mutex<()>>> = keys.iter().map(|k| process_mutex_for(k)).collect();
176 let mut proc_guards = Vec::with_capacity(mutexes.len());
177 let mut cross_guards = Vec::with_capacity(keys.len());
178 for (key, mutex) in keys.iter().zip(mutexes.iter()) {
179 proc_guards.push(mutex.lock().unwrap_or_else(|p| p.into_inner()));
180 cross_guards.push(CrossProcessLock::acquire(key)?);
181 }
182 f()
183}
184
185#[derive(Debug, Clone)]
187pub struct IssueDoc {
188 pub project: String,
190 pub path: PathBuf,
192 pub preamble: String,
194 pub tag_settings: TagSettings,
196 pub headings: Vec<IssueHeading>,
198 after: Vec<String>,
202}
203
204impl IssueDoc {
205 pub fn empty(project: &str, path: PathBuf) -> Self {
207 let preamble = default_preamble(project);
208 IssueDoc {
209 project: project.to_string(),
210 path,
211 tag_settings: tag_settings_from_preamble(&preamble),
212 preamble,
213 headings: Vec::new(),
214 after: Vec::new(),
215 }
216 }
217
218 pub fn parse_file(project: &str, path: &Path) -> Result<Self> {
224 if !path.exists() {
225 return Ok(Self::empty(project, path.to_path_buf()));
226 }
227 let content =
228 fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
229 Self::parse(project, path.to_path_buf(), &content)
230 }
231
232 pub fn parse(project: &str, path: PathBuf, content: &str) -> Result<Self> {
240 let lines: Vec<&str> = content.lines().collect();
241 let preamble_end = {
242 let mut nest = OrgScan::new();
243 lines
244 .iter()
245 .position(|line| {
246 if nest.observe(line) {
247 return false;
248 }
249 is_headline(line)
250 })
251 .unwrap_or(lines.len())
252 };
253 let raw_preamble = if preamble_end == 0 {
254 String::new()
255 } else {
256 lines[..preamble_end].join("\n").trim_end().to_string()
257 };
258 let settings = crate::org::merge_setupfile_settings(&raw_preamble, path.parent());
259 let keyword_lines: Vec<&str> = settings.lines().chain(lines.iter().copied()).collect();
263 let keywords = todo_keywords_from_lines(&keyword_lines);
264 let headline_at: Vec<bool> = (0..lines.len())
270 .map(|i| is_vissue_headline(&lines, i, &keywords))
271 .collect();
272 let mut nest = OrgScan::new();
273 let first_heading = lines
274 .iter()
275 .enumerate()
276 .position(|(i, line)| {
277 if nest.observe(line) {
278 return false;
279 }
280 headline_at[i]
281 })
282 .unwrap_or(lines.len());
283 let preamble = if first_heading == 0 {
284 default_preamble(project)
285 } else {
286 lines[..first_heading].join("\n").trim_end().to_string()
287 };
288 let default_priority = crate::org::priorities_from_preamble(&settings).default;
290 let mut headings = Vec::new();
291 let mut after = Vec::new();
292 let mut i = first_heading;
293 while i < lines.len() {
294 if !headline_at[i] {
295 i += 1;
296 continue;
297 }
298 let (heading, body_end) = parse_heading(&lines, i, &keywords, default_priority)
299 .with_context(|| format!("at {}:{}", path.display(), i + 1))?;
300 headings.push(heading);
301 i = body_end;
302 let inter_start = i;
303 let mut nest = OrgScan::new();
304 while i < lines.len() {
305 if !nest.observe(lines[i]) && headline_at[i] {
306 break;
307 }
308 i += 1;
309 }
310 let raw = lines[inter_start..i].join("\n");
311 after.push(if raw.trim().is_empty() {
312 String::new()
313 } else {
314 raw.trim_start_matches('\n').to_string()
315 });
316 }
317 let parent = path.parent().map(std::path::Path::to_path_buf);
318 Ok(IssueDoc {
319 project: project.to_string(),
320 path,
321 tag_settings: tag_settings_from_preamble(&crate::org::merge_setupfile_settings(
322 &preamble,
323 parent.as_deref(),
324 )),
325 preamble,
326 headings,
327 after,
328 })
329 }
330
331 pub fn render_string(&self) -> String {
333 let mut out = String::new();
334 let preamble = if self.preamble.trim().is_empty() {
335 default_preamble(&self.project)
336 } else {
337 ensure_org_preamble(&self.preamble, &self.project)
338 };
339 out.push_str(preamble.trim_end());
340 out.push_str("\n\n");
341 for (i, h) in self.headings.iter().enumerate() {
342 out.push_str(&h.render());
343 out.push('\n');
344 if let Some(extra) = self.after.get(i)
345 && !extra.is_empty()
346 {
347 out.push_str(extra);
348 if !extra.ends_with('\n') {
349 out.push('\n');
350 }
351 }
352 }
353 out
354 }
355
356 pub fn write(&self) -> Result<()> {
364 if let Some(parent) = self.path.parent() {
365 fs::create_dir_all(parent)?;
366 }
367 let out = self.render_string();
368 let seq = WRITE_TMP_SEQ.fetch_add(1, Ordering::Relaxed);
371 let nanos = SystemTime::now()
372 .duration_since(UNIX_EPOCH)
373 .map(|d| d.as_nanos())
374 .unwrap_or(0);
375 let base = self
376 .path
377 .file_name()
378 .and_then(|s| s.to_str())
379 .unwrap_or("issues.org");
380 let tmp = self
381 .path
382 .parent()
383 .unwrap_or_else(|| Path::new("."))
384 .join(format!(
385 ".{}.tmp.{}-{}-{}",
386 base,
387 std::process::id(),
388 nanos,
389 seq
390 ));
391 if let Err(e) = write_synced(&tmp, out.as_bytes()) {
395 let _ = fs::remove_file(&tmp);
396 return Err(e)
397 .with_context(|| format!("write temp {}", tmp.display()))
398 .map_err(crate::error::Error::from);
399 }
400 if let Err(e) = fs::rename(&tmp, &self.path) {
401 let _ = fs::remove_file(&tmp);
402 return Err(e)
403 .with_context(|| format!("rename {} -> {}", tmp.display(), self.path.display()))
404 .map_err(crate::error::Error::from);
405 }
406 self.announce_write();
407 Ok(())
408 }
409
410 fn announce_write(&self) {
414 if !crate::events::enabled() {
415 return;
416 }
417 let Some(dir) = self.path.parent().and_then(|p| p.parent()) else {
418 return;
419 };
420 let _ = crate::events::emit_issues_write(dir, &self.project, &self.path);
421 let _ = crate::events::ensure_gitignore_hint(dir);
422 }
423
424 pub fn priority_spec(&self) -> crate::org::PrioritySpec {
426 crate::org::priorities_from_preamble(&self.merged_inbuffer_settings())
427 }
428
429 pub fn priorities_are_named(&self) -> bool {
431 crate::org::preamble_has_keyword(&self.merged_inbuffer_settings(), "PRIORITIES")
432 }
433
434 pub fn default_create_priority(&self, cfg_default: char) -> char {
436 if self.priorities_are_named() {
437 self.priority_spec().default
438 } else {
439 cfg_default
440 }
441 }
442
443 fn merged_inbuffer_settings(&self) -> String {
444 crate::org::merge_setupfile_settings(&self.preamble, self.path.parent())
445 }
446
447 pub fn known_ids(&self) -> Vec<String> {
449 self.headings.iter().map(|h| h.id.clone()).collect()
450 }
451
452 pub fn upsert(&mut self, heading: IssueHeading) {
454 if let Some(slot) = self.headings.iter_mut().find(|h| h.id == heading.id) {
455 *slot = heading;
456 } else {
457 self.headings.push(heading);
458 self.after.push(String::new());
459 }
460 }
461
462 pub fn remove(&mut self, id: &str) -> Option<IssueHeading> {
464 let idx = self.headings.iter().position(|h| h.id == id)?;
465 let heading = self.headings.remove(idx);
466 let extra = if idx < self.after.len() {
467 self.after.remove(idx)
468 } else {
469 String::new()
470 };
471 if !extra.trim().is_empty() {
472 if idx == 0 {
473 if !self.preamble.is_empty() && !self.preamble.ends_with('\n') {
474 self.preamble.push('\n');
475 }
476 if !self.preamble.is_empty() {
477 self.preamble.push('\n');
478 }
479 self.preamble.push_str(&extra);
480 } else if let Some(prev) = self.after.get_mut(idx - 1) {
481 if !prev.is_empty() && !prev.ends_with('\n') {
482 prev.push('\n');
483 }
484 prev.push_str(&extra);
485 }
486 }
487 Some(heading)
488 }
489}
490
491fn is_vissue_headline(lines: &[&str], i: usize, keywords: &[String]) -> bool {
492 if !is_issue_headline(lines[i], keywords) {
493 return false;
494 }
495 peek_heading_id(lines, i).is_none_or(|id| !crate::org::is_gcal_event_id(id))
496}
497
498fn peek_heading_id<'a>(lines: &[&'a str], start: usize) -> Option<&'a str> {
505 let mut i = start + 1;
506 while i < lines.len() && !parse_planning_line(lines[i]).is_empty() {
507 i += 1;
508 }
509 while i < lines.len() {
510 let trimmed = lines[i].trim();
511 if trimmed.is_empty() {
512 i += 1;
513 continue;
514 }
515 if !opens_a_drawer(trimmed) {
516 break;
517 }
518 if trimmed.eq_ignore_ascii_case(":PROPERTIES:") {
519 i += 1;
520 while i < lines.len() && !lines[i].trim().eq_ignore_ascii_case(":END:") {
521 let line = lines[i].trim();
522 if let Some(rest) = line.strip_prefix(':')
523 && let Some((key, value)) = rest.split_once(':')
524 {
525 let (key, _) = property_key_and_append(key.trim());
526 if key.eq_ignore_ascii_case("ID") {
527 let value = value.trim();
528 if !value.is_empty() {
529 return Some(value);
530 }
531 }
532 }
533 i += 1;
534 }
535 return None;
536 }
537 i += 1;
538 while i < lines.len() && !lines[i].trim().eq_ignore_ascii_case(":END:") {
539 i += 1;
540 }
541 if i < lines.len() {
542 i += 1;
543 }
544 }
545 None
546}
547
548fn parse_heading(
549 lines: &[&str],
550 start: usize,
551 keywords: &[String],
552 default_priority: char,
553) -> Result<(IssueHeading, usize)> {
554 let header = lines[start];
555 let stripped = header
556 .strip_prefix("* ")
557 .ok_or_else(|| anyhow!("not a heading"))?;
558 let bits = parse_headline_bits(stripped, keywords);
559 let state = bits
560 .keyword
561 .ok_or_else(|| anyhow!("not an issue heading"))?
562 .to_string();
563 let priority = bits.priority.unwrap_or(default_priority);
564 let (title_and_cookies, org_tags) = crate::model::split_headline_tags(bits.rest);
565 let (title, statistics) = split_statistics_cookies(&title_and_cookies);
566
567 let mut properties = BTreeMap::new();
568 let mut property_order = Vec::new();
569 let mut logbook: Vec<LogEntry> = Vec::new();
570 let mut extra_drawers: Vec<String> = Vec::new();
571 let mut i = start + 1;
572
573 while i < lines.len() {
577 let found = parse_planning_line(lines[i]);
578 if found.is_empty() {
579 break;
580 }
581 for (key, value) in found {
582 if !property_order.contains(&key) {
583 property_order.push(key.clone());
584 }
585 properties.insert(key, value);
586 }
587 i += 1;
588 }
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(idx) = rest.find(':')
605 {
606 let raw_key = &rest[..idx];
607 let (key, append) = property_key_and_append(raw_key);
608 let val = rest[idx + 1..].trim().to_string();
609 if append {
610 properties
611 .entry(key.to_string())
612 .and_modify(|existing| {
613 if !val.is_empty() {
614 if !existing.is_empty() {
615 existing.push(' ');
616 }
617 existing.push_str(&val);
618 }
619 })
620 .or_insert(val);
621 } else {
622 properties.insert(key.to_string(), val);
623 }
624 if !property_order.iter().any(|k| k == key) {
625 property_order.push(key.to_string());
626 }
627 }
628 i += 1;
629 }
630 if i < lines.len() {
631 i += 1;
632 }
633 continue;
634 }
635 if trimmed.eq_ignore_ascii_case(":LOGBOOK:") {
636 i += 1;
637 while i < lines.len() && !lines[i].trim().eq_ignore_ascii_case(":END:") {
638 if !lines[i].trim().is_empty() {
639 logbook.push(parse_log_line(lines[i]));
640 }
641 i += 1;
642 }
643 if i < lines.len() {
644 i += 1;
645 }
646 continue;
647 }
648 let drawer_start = i;
649 i += 1;
650 while i < lines.len() && !lines[i].trim().eq_ignore_ascii_case(":END:") {
651 i += 1;
652 }
653 if i < lines.len() {
654 i += 1;
655 }
656 let mut drawer = lines[drawer_start..i].join("\n");
657 drawer.push('\n');
658 extra_drawers.push(drawer);
659 }
660
661 let body_start = i;
662 let mut body_end = body_start;
663 let mut nest = OrgScan::new();
664 while body_end < lines.len() {
665 if !nest.observe(lines[body_end]) && is_top_level_headline(lines[body_end]) {
666 break;
667 }
668 body_end += 1;
669 }
670 let body = lines[body_start..body_end]
671 .join("\n")
672 .trim_matches('\n')
673 .trim_end()
674 .to_string();
675
676 if let Some(legacy) = properties.remove(crate::model::LEGACY_TAGS_PROPERTY) {
680 property_order.retain(|key| key != crate::model::LEGACY_TAGS_PROPERTY);
681 properties
682 .entry(crate::model::TAGS_PROPERTY.to_string())
683 .or_insert(legacy);
684 }
685
686 let id = properties
687 .get("ID")
688 .cloned()
689 .ok_or_else(|| anyhow!(":ID: property missing"))?;
690
691 Ok((
692 IssueHeading {
693 id,
694 title,
695 state,
696 priority,
697 properties,
698 org_tags,
699 statistics,
700 property_order,
701 extra_drawers,
702 body,
703 logbook,
704 line_start: start + 1,
705 line_end: if body_end == 0 { 1 } else { body_end },
706 },
707 body_end,
708 ))
709}
710
711pub fn default_preamble(project: &str) -> String {
717 format!(
718 "#+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{}",
719 crate::org::PROTOCOL_VERSION,
720 crate::org::HOUSE_TAGS_LINES[0],
721 crate::org::HOUSE_TAGS_LINES[1],
722 crate::org::HOUSE_PRIORITIES_LINE,
723 today_inactive_bracket(),
724 TODO_HEADER
725 )
726}
727
728pub fn list_projects(layout: &Layout) -> Result<Vec<String>> {
734 let dir = layout.projects_dir();
735 if !dir.exists() {
736 return Ok(Vec::new());
737 }
738 let mut projects = Vec::new();
739 for entry in fs::read_dir(&dir).with_context(|| format!("read dir {}", dir.display()))? {
740 let entry = entry?;
741 let path = entry.path();
742 if path.is_dir()
743 && path.join("issues.org").exists()
744 && let Some(name) = path.file_name().and_then(|n| n.to_str())
745 {
746 projects.push(name.to_string());
747 }
748 }
749 projects.sort();
750 Ok(projects)
751}
752
753pub fn resolve_existing_project_case(layout: &Layout, project: &str) -> Result<String> {
760 if project.is_empty() {
761 return Ok(project.to_string());
762 }
763 if layout.project_issues_path(project).exists() {
764 return Ok(project.to_string());
765 }
766 let project_lower = project.to_lowercase();
767 let matches: Vec<String> = list_projects(layout)?
768 .into_iter()
769 .filter(|candidate| candidate.to_lowercase() == project_lower)
770 .collect();
771 match matches.as_slice() {
772 [] => Ok(project.to_string()),
773 [canonical] => Ok(canonical.clone()),
774 _ => Err(anyhow!(
775 "project {project:?} is ambiguous; case-insensitive matches: {}",
776 matches.join(", ")
777 )
778 .into()),
779 }
780}
781
782pub fn project_selected(project: &str, filter: Option<&str>) -> bool {
789 match filter {
790 None => true,
791 Some(p) => project.eq_ignore_ascii_case(p),
792 }
793}
794
795pub fn find_by_id(layout: &Layout, id: &str) -> Result<Option<(IssueHeading, PathBuf, String)>> {
801 for project in list_projects(layout)? {
802 let path = layout.project_issues_path(&project);
803 let doc = IssueDoc::parse_file(&project, &path)?;
804 for h in doc.headings {
805 if h.id == id {
806 return Ok(Some((h, path, project)));
807 }
808 }
809 }
810 Ok(None)
811}
812
813pub fn load_all(layout: &Layout) -> Result<Vec<(String, IssueHeading)>> {
819 use rayon::prelude::*;
823 let per_project: Vec<Vec<(String, IssueHeading)>> = list_projects(layout)?
824 .into_par_iter()
825 .map(|project| {
826 let path = layout.project_issues_path(&project);
827 let doc = IssueDoc::parse_file(&project, &path)?;
828 Ok(doc
829 .headings
830 .into_iter()
831 .map(|h| (project.clone(), h))
832 .collect())
833 })
834 .collect::<Result<Vec<_>>>()?;
835 Ok(per_project.into_iter().flatten().collect())
836}
837
838fn id_seed() -> u64 {
859 if let Ok(raw) = crate::process_env::var(ID_SEED_ENV)
860 && let Ok(seed) = raw.trim().parse::<u64>()
861 {
862 return seed;
863 }
864 SystemTime::now()
865 .duration_since(UNIX_EPOCH)
866 .map(|d| d.as_nanos() as u64)
867 .unwrap_or(1)
868}
869
870pub const ID_SEED_ENV: &str = "VISSUE_ID_SEED";
872
873pub fn generate_id(
907 project: &str,
908 subject: &str,
909 existing: &[String],
910 length: usize,
911) -> Result<String> {
912 let len = length.max(2);
913 let taken: std::collections::HashSet<&str> = existing.iter().map(String::as_str).collect();
914 let mut material = Vec::with_capacity(project.len() + subject.len() + 1);
919 material.extend_from_slice(project.as_bytes());
920 material.push(0);
921 material.extend_from_slice(subject.as_bytes());
922 let start = xxh3_64_with_seed(&material, id_seed());
923 let attempts = 36usize
926 .checked_pow(len as u32)
927 .map(|space| space.saturating_mul(2))
928 .unwrap_or(usize::MAX)
929 .min(2_000_000);
930 for counter in 0..attempts as u64 {
931 let mut n = start.wrapping_add(counter);
932 let mut suffix = String::new();
933 for _ in 0..len {
934 suffix.push(ID_ALPHABET[(n % 36) as usize] as char);
935 n /= 36;
936 }
937 let id = format!("{}-{}", project, suffix);
938 if !taken.contains(id.as_str()) {
939 return Ok(id);
940 }
941 }
942 Err(anyhow!(
943 "no free id left for {project:?} at id_length = {len}; \
944 raise `id_length` under [issues] in vissue.toml"
945 )
946 .into())
947}
948
949pub fn detect_project_from_ctx(start: &Path) -> Option<String> {
951 let mut dir = start.canonicalize().ok()?;
952 loop {
953 let candidate = dir.join(".project-ctx.toml");
954 if candidate.exists()
955 && let Ok(text) = fs::read_to_string(&candidate)
956 && let Ok(value) = toml::from_str::<toml::Value>(&text)
960 && let Some(name) = value
961 .get("project")
962 .and_then(|p| p.get("name"))
963 .and_then(|n| n.as_str())
964 {
965 return Some(name.to_string());
966 }
967 if !dir.pop() {
968 break;
969 }
970 }
971 None
972}
973
974pub fn find_org_ids(
985 layout: &Layout,
986 wanted: &std::collections::HashSet<String>,
987) -> Result<std::collections::HashSet<String>> {
988 let mut found = std::collections::HashSet::new();
989 if wanted.is_empty() {
990 return Ok(found);
991 }
992 let dir = layout.projects_dir();
993 if !dir.exists() {
994 return Ok(found);
995 }
996 for entry in walkdir::WalkDir::new(&dir)
997 .into_iter()
998 .filter_entry(|e| !is_skipped_dir(e))
999 {
1000 let entry = match entry {
1001 Ok(e) => e,
1002 Err(_) => continue,
1003 };
1004 if !entry.file_type().is_file()
1005 || entry.path().extension().and_then(|s| s.to_str()) != Some("org")
1006 {
1007 continue;
1008 }
1009 let content = fs::read_to_string(entry.path())
1010 .with_context(|| format!("read {}", entry.path().display()))?;
1011 for id in org_ids(&content) {
1012 if crate::org::is_gcal_event_id(id) {
1013 continue;
1014 }
1015 if wanted.contains(id) {
1016 found.insert(id.to_string());
1017 if found.len() == wanted.len() {
1018 return Ok(found);
1019 }
1020 }
1021 }
1022 }
1023 Ok(found)
1024}
1025
1026pub fn collect_org_ids(layout: &Layout) -> Result<std::collections::HashSet<String>> {
1032 let mut ids = std::collections::HashSet::new();
1033 let dir = layout.projects_dir();
1034 if !dir.exists() {
1035 return Ok(ids);
1036 }
1037 for entry in walkdir::WalkDir::new(&dir)
1038 .into_iter()
1039 .filter_entry(|e| !is_skipped_dir(e))
1040 {
1041 let entry = match entry {
1042 Ok(e) => e,
1043 Err(_) => continue,
1044 };
1045 if !entry.file_type().is_file()
1046 || entry.path().extension().and_then(|s| s.to_str()) != Some("org")
1047 {
1048 continue;
1049 }
1050 let content = fs::read_to_string(entry.path())
1051 .with_context(|| format!("read {}", entry.path().display()))?;
1052 for id in org_ids(&content) {
1053 if !crate::org::is_gcal_event_id(id) {
1054 ids.insert(id.to_string());
1055 }
1056 }
1057 }
1058 Ok(ids)
1059}
1060
1061fn is_skipped_dir(entry: &walkdir::DirEntry) -> bool {
1062 if !entry.file_type().is_dir() {
1063 return false;
1064 }
1065 let name = entry.file_name().to_string_lossy();
1066 matches!(
1067 name.as_ref(),
1068 "node_modules" | "target" | ".git" | ".cache" | "build"
1069 )
1070}
1071
1072fn org_id_property_value(line: &str) -> Option<&str> {
1073 let value = line.trim_start().strip_prefix(":ID:")?.trim();
1074 if value.is_empty() { None } else { Some(value) }
1075}
1076
1077pub(crate) fn org_ids(content: &str) -> impl Iterator<Item = &str> {
1089 let mut at_drawer_site = true;
1091 let mut in_drawer = false;
1092 let mut drawer_is_properties = false;
1093 let mut under_headline = false;
1096 let mut nest = OrgScan::new();
1097
1098 content.lines().filter_map(move |line| {
1099 let trimmed = line.trim();
1100
1101 if in_drawer {
1102 if trimmed.eq_ignore_ascii_case(":END:") {
1103 in_drawer = false;
1104 drawer_is_properties = false;
1105 at_drawer_site = true;
1108 return None;
1109 }
1110 if drawer_is_properties {
1111 return org_id_property_value(line);
1112 }
1113 return None;
1114 }
1115
1116 if nest.observe(line) {
1119 at_drawer_site = false;
1120 under_headline = false;
1121 return None;
1122 }
1123
1124 if is_headline(line) {
1125 at_drawer_site = true;
1126 under_headline = true;
1127 return None;
1128 }
1129 let planning_may_start_here = under_headline;
1130 under_headline = false;
1131
1132 if trimmed.is_empty() || trimmed.starts_with("#+") {
1135 return None;
1136 }
1137 if at_drawer_site {
1138 if opens_a_drawer(trimmed) {
1139 in_drawer = true;
1140 drawer_is_properties = trimmed.eq_ignore_ascii_case(":PROPERTIES:");
1141 return None;
1142 }
1143 if planning_may_start_here && is_planning_line(trimmed) {
1144 return None;
1145 }
1146 }
1147 at_drawer_site = false;
1149 None
1150 })
1151}
1152
1153#[cfg(test)]
1154mod tests {
1155 use super::*;
1156 use crate::config::DEFAULT_PREFIX;
1157
1158 fn sample_heading() -> IssueHeading {
1159 let mut props = BTreeMap::new();
1160 props.insert("ID".into(), "sample-abc1".into());
1161 props.insert("CREATED".into(), "[2026-04-25 Sat]".into());
1162 props.insert("TYPE".into(), "feature".into());
1163 IssueHeading {
1164 id: "sample-abc1".into(),
1165 title: "Add a thing".into(),
1166 state: "TODO".into(),
1167 priority: 'A',
1168 properties: props,
1169 org_tags: Vec::new(),
1170 statistics: None,
1171 property_order: vec!["ID".into(), "CREATED".into(), "TYPE".into()],
1172 extra_drawers: Vec::new(),
1173 body: "Some body lines.\nWith multiple lines.".into(),
1174 logbook: Vec::new(),
1175 line_start: 4,
1176 line_end: 12,
1177 }
1178 }
1179
1180 #[test]
1181 fn render_then_parse_preserves_the_heading() {
1182 let mut content = String::from("#+TITLE: sample issues\n");
1183 content.push_str(TODO_HEADER);
1184 content.push_str("\n\n");
1185 content.push_str(&sample_heading().render());
1186 let parsed = IssueDoc::parse("sample", PathBuf::from("/tmp/x.org"), &content).unwrap();
1187 let h = &parsed.headings[0];
1188 let original = sample_heading();
1189 assert_eq!(h.id, original.id);
1190 assert_eq!(h.title, original.title);
1191 assert_eq!(h.state, original.state);
1192 assert_eq!(h.priority, original.priority);
1193 assert_eq!(h.body, original.body);
1194 assert_eq!(
1195 crate::props::get(&h.properties, crate::props::TYPE),
1196 crate::props::get(&original.properties, crate::props::TYPE)
1197 );
1198 }
1199
1200 #[test]
1201 fn heading_without_a_priority_cookie_defaults_to_c() {
1202 let content =
1203 "#+TITLE: x issues\n\n* TODO Just a title\n:PROPERTIES:\n:ID: x-aaaa\n:END:\n";
1204 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1205 assert_eq!(doc.headings[0].priority, 'C');
1206 assert_eq!(doc.headings[0].title, "Just a title");
1207 }
1208
1209 #[test]
1210 fn a_multibyte_priority_cookie_parses_instead_of_panicking() {
1211 let content = "#+TITLE: x issues\n\n* TODO [#\u{2192}] Hand edited\n:PROPERTIES:\n:ID: x-aaaa\n:END:\n";
1212 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1213 assert_eq!(doc.headings[0].priority, '\u{2192}');
1214 assert_eq!(doc.headings[0].title, "Hand edited");
1215 }
1216
1217 #[test]
1218 fn a_title_opening_with_a_bracket_keeps_its_text() {
1219 let content = "#+TITLE: x issues\n\n* TODO [#not a cookie] stays\n:PROPERTIES:\n:ID: x-bbbb\n:END:\n";
1220 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1221 assert_eq!(doc.headings[0].priority, 'C');
1222 assert_eq!(doc.headings[0].title, "[#not a cookie] stays");
1223 }
1224
1225 #[test]
1226 fn an_org_planning_line_parses_instead_of_hiding_the_drawer() {
1227 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";
1231 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1232 let h = &doc.headings[0];
1233 assert_eq!(h.id, "x-aaaa");
1234 assert_eq!(h.deadline(), Some("<2026-09-01 Tue>"));
1235 assert_eq!(h.scheduled(), Some("<2026-09-05 Sat>"));
1236 assert_eq!(
1237 h.properties.get("CLOSED").map(String::as_str),
1238 Some("[2026-08-14 Fri 03:33]")
1239 );
1240 assert_eq!(h.body, "Body stays body.");
1241 }
1242
1243 #[test]
1244 fn a_planning_line_round_trips_in_orgs_own_order() {
1245 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";
1246 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1247 let rendered = doc.headings[0].render();
1248 assert!(
1249 rendered.contains(
1250 "\nCLOSED: [2026-08-14 Fri 03:33] SCHEDULED: <2026-09-05 Sat> DEADLINE: <2026-09-01 Tue>\n"
1251 ),
1252 "{rendered}"
1253 );
1254 assert!(!rendered.contains(":DEADLINE:"), "{rendered}");
1255 }
1256
1257 #[test]
1258 fn a_legacy_date_property_is_promoted_to_a_planning_line() {
1259 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";
1262 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1263 assert_eq!(doc.headings[0].deadline(), Some("<2026-09-01 Tue>"));
1264 let rendered = doc.headings[0].render();
1265 assert!(
1266 rendered.contains("\nDEADLINE: <2026-09-01 Tue>\n"),
1267 "{rendered}"
1268 );
1269 assert!(!rendered.contains(":DEADLINE:"), "{rendered}");
1270 }
1271
1272 #[test]
1273 fn a_line_that_only_looks_like_planning_is_left_as_body() {
1274 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";
1275 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1276 assert_eq!(doc.headings[0].deadline(), None);
1277 assert_eq!(
1278 doc.headings[0].body,
1279 "DEADLINE: is discussed in the design note."
1280 );
1281 }
1282
1283 #[test]
1284 fn a_legacy_tags_property_moves_to_the_name_org_leaves_alone() {
1285 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";
1288 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1289 let h = &doc.headings[0];
1290 assert_eq!(h.tags(), vec!["needs-review", "perf"]);
1291 let rendered = h.render();
1292 assert!(
1293 rendered.contains(":VISSUE_TAGS: needs-review"),
1294 "{rendered}"
1295 );
1296 assert!(rendered.contains(":perf:"), "{rendered}");
1297 assert!(!rendered.contains(":TAGS:"), "{rendered}");
1298 }
1299
1300 #[test]
1301 fn bodies_end_at_the_next_heading() {
1302 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";
1303 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1304 assert_eq!(doc.headings.len(), 2);
1305 assert_eq!(doc.headings[0].body, "First body.");
1306 assert_eq!(doc.headings[1].body, "Second body.\nMulti.");
1307 }
1308
1309 #[test]
1310 fn logbook_survives_a_document_round_trip() {
1311 let mut h = sample_heading();
1312 h.logbook = vec![LogEntry {
1313 timestamp: "[2026-04-26 Sun 09:15]".into(),
1314 from_state: Some("TODO".into()),
1315 to_state: Some("STARTED".into()),
1316 note: None,
1317 raw: None,
1318 }];
1319 let mut content = String::from("#+TITLE: sample issues\n\n");
1320 content.push_str(&h.render());
1321 let parsed = IssueDoc::parse("sample", PathBuf::from("/tmp/x.org"), &content).unwrap();
1322 assert_eq!(parsed.headings[0].logbook.len(), 1);
1323 assert_eq!(
1324 parsed.headings[0].logbook[0].from_state.as_deref(),
1325 Some("TODO")
1326 );
1327 }
1328
1329 #[test]
1330 fn write_then_reread_finds_the_heading() {
1331 let dir = tempfile::tempdir().unwrap();
1332 let path = dir.path().join("Software/sample/issues.org");
1333 IssueDoc {
1334 project: "sample".into(),
1335 path: path.clone(),
1336 preamble: default_preamble("sample"),
1337 tag_settings: tag_settings_from_preamble(&default_preamble("sample")),
1338 headings: vec![sample_heading()],
1339 after: vec![String::new()],
1340 }
1341 .write()
1342 .unwrap();
1343 let parsed = IssueDoc::parse_file("sample", &path).unwrap();
1344 assert_eq!(parsed.headings[0].id, "sample-abc1");
1345 }
1346
1347 #[test]
1348 fn write_preserves_the_existing_preamble_and_property_order() {
1349 let dir = tempfile::tempdir().unwrap();
1350 let path = dir.path().join("Software/sample/issues.org");
1351 fs::create_dir_all(path.parent().unwrap()).unwrap();
1352 fs::write(
1353 &path,
1354 "#+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",
1355 )
1356 .unwrap();
1357
1358 let mut doc = IssueDoc::parse_file("sample", &path).unwrap();
1359 doc.headings[0].priority = 'B';
1360 doc.write().unwrap();
1361 let written = fs::read_to_string(&path).unwrap();
1362
1363 assert!(written.contains("#+FILETAGS: :issues:sample:"), "{written}");
1364 assert!(written.contains("#+CATEGORY: sample"), "{written}");
1365 assert!(written.contains("#+STATUS: Active"), "{written}");
1366 assert!(
1367 written.find(":TYPE:").unwrap() < written.find(":PARENT:").unwrap(),
1368 "{written}"
1369 );
1370 }
1371
1372 #[test]
1373 fn a_gcal_event_heading_is_not_an_issue() {
1374 let dir = tempfile::tempdir().unwrap();
1375 let path = dir.path().join("Software/sample/issues.org");
1376 fs::create_dir_all(path.parent().unwrap()).unwrap();
1377 fs::write(
1378 &path,
1379 "#+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",
1380 )
1381 .unwrap();
1382 let doc = IssueDoc::parse_file("sample", &path).unwrap();
1383 assert_eq!(
1384 doc.headings.len(),
1385 1,
1386 "{:?}",
1387 doc.headings.iter().map(|h| &h.id).collect::<Vec<_>>()
1388 );
1389 assert_eq!(doc.headings[0].id, "sample-aaaa");
1390 let written = doc.render_string();
1391 assert!(
1392 written.contains("abc123/primary@group.calendar.google.com"),
1393 "{written}"
1394 );
1395 }
1396
1397 #[test]
1398 fn setupfile_todo_keywords_make_an_issue() {
1399 let dir = tempfile::tempdir().unwrap();
1400 let setup = dir.path().join("house.org");
1401 fs::write(&setup, "#+TODO: TODO HOLD | DONE\n").unwrap();
1402 let path = dir.path().join("Software/sample/issues.org");
1403 fs::create_dir_all(path.parent().unwrap()).unwrap();
1404 fs::write(
1405 &path,
1406 format!(
1407 "#+TITLE: sample issues\n#+SETUPFILE: {}\n\n* HOLD [#C] Parked\n:PROPERTIES:\n:ID: sample-hold\n:END:\n",
1408 setup.display()
1409 ),
1410 )
1411 .unwrap();
1412 let doc = IssueDoc::parse_file("sample", &path).unwrap();
1413 assert_eq!(doc.headings.len(), 1);
1414 assert_eq!(doc.headings[0].state, "HOLD");
1415 assert_eq!(doc.priority_spec().default, 'C');
1416 }
1417
1418 #[test]
1419 fn file_priorities_set_the_missing_cookie() {
1420 let dir = tempfile::tempdir().unwrap();
1421 let path = dir.path().join("Software/sample/issues.org");
1422 fs::create_dir_all(path.parent().unwrap()).unwrap();
1423 fs::write(
1424 &path,
1425 "#+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",
1426 )
1427 .unwrap();
1428 let doc = IssueDoc::parse_file("sample", &path).unwrap();
1429 assert_eq!(doc.headings[0].priority, 'B');
1430 assert_eq!(doc.priority_spec().lowest, 'D');
1431 }
1432
1433 #[test]
1434 fn an_empty_document_writes_the_house_preamble() {
1435 let dir = tempfile::tempdir().unwrap();
1436 let path = dir.path().join("Software/sample/issues.org");
1437 IssueDoc::empty("sample", path.clone()).write().unwrap();
1438 let written = fs::read_to_string(&path).unwrap();
1439 for expected in [
1440 "#+TITLE: sample issues",
1441 "#+VISSUE: 1",
1442 "#+CATEGORY: sample",
1445 "#+FILETAGS: :issues:sample:noexport:",
1446 "#+TAGS: { bug(b) feature(f) task(t) chore(c) plan(p) }",
1447 "#+PRIORITIES: A C C",
1448 "#+EXCLUDE_TAGS: noexport",
1449 "#+SELECT_TAGS: export",
1450 "#+DATE:",
1451 "#+STATUS: Active",
1452 TODO_HEADER,
1453 ] {
1454 assert!(written.contains(expected), "missing {expected}: {written}");
1455 }
1456 }
1457
1458 #[test]
1459 fn generated_ids_are_unique_and_sized() {
1460 let existing = vec!["p-aaaa".to_string()];
1461 let id = generate_id("p", "a subject", &existing, 4).unwrap();
1462 assert!(id.starts_with("p-"));
1463 assert!(!existing.contains(&id));
1464 assert_eq!(id.len(), 1 + 1 + 4);
1465 assert_eq!(generate_id("q", "t", &[], 6).unwrap().len(), 1 + 1 + 6);
1466 }
1467
1468 #[test]
1469 fn a_full_suffix_space_is_an_error_and_not_a_panic() {
1470 let mut existing = Vec::new();
1473 for a in ID_ALPHABET {
1474 for b in ID_ALPHABET {
1475 existing.push(format!("p-{}{}", *a as char, *b as char));
1476 }
1477 }
1478 let err = generate_id("p", "t", &existing, 2).unwrap_err();
1479 assert!(err.to_string().contains("id_length"), "{err}");
1480 existing.pop();
1482 assert!(generate_id("p", "t", &existing, 2).is_ok());
1483 }
1484
1485 #[test]
1486 fn projects_are_discovered_under_the_configured_prefix() {
1487 let dir = tempfile::tempdir().unwrap();
1488 let layout = Layout::new(dir.path(), "tracker");
1489 for project in ["beta", "alpha"] {
1490 IssueDoc::empty(project, layout.project_issues_path(project))
1491 .write()
1492 .unwrap();
1493 }
1494 assert_eq!(list_projects(&layout).unwrap(), vec!["alpha", "beta"]);
1495 assert!(
1496 list_projects(&Layout::new(dir.path(), DEFAULT_PREFIX))
1497 .unwrap()
1498 .is_empty()
1499 );
1500 }
1501
1502 #[test]
1503 fn project_case_resolves_to_the_directory_on_disk() {
1504 let dir = tempfile::tempdir().unwrap();
1505 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
1506 IssueDoc::empty("MixedCase", layout.project_issues_path("MixedCase"))
1507 .write()
1508 .unwrap();
1509 assert_eq!(
1510 resolve_existing_project_case(&layout, "mixedcase").unwrap(),
1511 "MixedCase"
1512 );
1513 assert_eq!(
1514 resolve_existing_project_case(&layout, "brand-new").unwrap(),
1515 "brand-new"
1516 );
1517 }
1518
1519 #[test]
1520 fn project_context_file_is_found_by_walking_up() {
1521 let dir = tempfile::tempdir().unwrap();
1522 let nested = dir.path().join("a/b/c");
1523 fs::create_dir_all(&nested).unwrap();
1524 fs::write(
1525 dir.path().join(".project-ctx.toml"),
1526 "[project]\nname = \"demoproj\"\n",
1527 )
1528 .unwrap();
1529 assert_eq!(
1530 detect_project_from_ctx(&nested).as_deref(),
1531 Some("demoproj")
1532 );
1533 let empty = tempfile::tempdir().unwrap();
1534 assert!(detect_project_from_ctx(empty.path()).is_none());
1535 }
1536
1537 fn ids(content: &str) -> Vec<&str> {
1538 org_ids(content).collect()
1539 }
1540
1541 #[test]
1542 fn a_drawer_under_a_headline_defines_an_id() {
1543 assert_eq!(
1544 ids("* TODO [#B] a title\n:PROPERTIES:\n:ID: atlas-1a2b\n:END:\n"),
1545 ["atlas-1a2b"]
1546 );
1547 }
1548
1549 #[test]
1550 fn a_drawer_under_the_planning_line_defines_an_id() {
1551 let text = concat!(
1552 "* TODO a title\n",
1553 "DEADLINE: <2026-05-15 Fri>\n",
1554 ":PROPERTIES:\n",
1555 ":ID: atlas-1a2b\n",
1556 ":END:\n",
1557 );
1558 assert_eq!(ids(text), ["atlas-1a2b"]);
1559 }
1560
1561 #[test]
1562 fn a_logbook_beside_the_properties_does_not_hide_it() {
1563 let text = concat!(
1564 "* TODO a title\n",
1565 ":LOGBOOK:\n",
1566 "- claimed by worker-1\n",
1567 ":END:\n",
1568 ":PROPERTIES:\n",
1569 ":ID: atlas-1a2b\n",
1570 ":END:\n",
1571 );
1572 assert_eq!(ids(text), ["atlas-1a2b"]);
1573 }
1574
1575 #[test]
1576 fn an_id_quoted_in_a_body_defines_nothing() {
1577 let text = concat!(
1579 "* TODO a title\n",
1580 ":PROPERTIES:\n",
1581 ":ID: atlas-1a2b\n",
1582 ":END:\n",
1583 "\n",
1584 "The heading I was handed reads:\n",
1585 ":PROPERTIES:\n",
1586 ":ID: ghost-9999\n",
1587 ":END:\n",
1588 );
1589 assert_eq!(
1590 ids(text),
1591 ["atlas-1a2b"],
1592 "a report that quotes an id defined it"
1593 );
1594 }
1595
1596 #[test]
1597 fn a_bare_id_line_in_a_body_defines_nothing() {
1598 let text = concat!(
1599 "* TODO a title\n",
1600 ":PROPERTIES:\n",
1601 ":ID: atlas-1a2b\n",
1602 ":END:\n",
1603 "\n",
1604 "Compare with :ID: ghost-9999 in the other file.\n",
1605 ":ID: ghost-8888\n",
1606 );
1607 assert_eq!(ids(text), ["atlas-1a2b"]);
1608 }
1609
1610 #[test]
1611 fn a_file_level_drawer_defines_an_id() {
1612 let text = concat!(
1614 "#+TITLE: atlas issues\n",
1615 "\n",
1616 ":PROPERTIES:\n",
1617 ":ID: the-file-itself\n",
1618 ":END:\n",
1619 "\n",
1620 "* TODO a title\n",
1621 ":PROPERTIES:\n",
1622 ":ID: atlas-1a2b\n",
1623 ":END:\n",
1624 );
1625 assert_eq!(ids(text), ["the-file-itself", "atlas-1a2b"]);
1626 }
1627
1628 #[test]
1629 fn every_headline_depth_opens_a_drawer_site() {
1630 let text = concat!(
1631 "* TODO a title\n",
1632 ":PROPERTIES:\n",
1633 ":ID: atlas-1a2b\n",
1634 ":END:\n",
1635 "** A sub-heading someone wrote by hand\n",
1636 ":PROPERTIES:\n",
1637 ":ID: atlas-3c4d\n",
1638 ":END:\n",
1639 );
1640 assert_eq!(ids(text), ["atlas-1a2b", "atlas-3c4d"]);
1641 }
1642
1643 #[test]
1644 fn a_planning_keyword_needs_its_colon() {
1645 assert!(is_planning_line("DEADLINE: <2026-05-15 Fri>"));
1646 assert!(is_planning_line("CLOSED: [2026-05-15 Fri]"));
1647 assert!(!is_planning_line("DEADLINES slipped again"));
1649 assert!(!is_planning_line("SCHEDULED work for the week"));
1650 }
1651
1652 #[test]
1653 fn prose_that_opens_like_a_planning_line_still_ends_the_drawer_site() {
1654 let text = concat!(
1658 "* TODO a title\n",
1659 ":PROPERTIES:\n",
1660 ":ID: atlas-1a2b\n",
1661 ":END:\n",
1662 "\n",
1663 "DEADLINE: is discussed in the design note.\n",
1664 ":PROPERTIES:\n",
1665 ":ID: ghost-9999\n",
1666 ":END:\n",
1667 );
1668 assert_eq!(ids(text), ["atlas-1a2b"]);
1669 }
1670
1671 #[test]
1672 fn a_headline_needs_a_space_after_its_stars() {
1673 assert!(is_headline("* TODO a title"));
1674 assert!(is_headline("*** deeper"));
1675 assert!(!is_headline("**bold** at the start of a line"));
1676 assert!(!is_headline("not a headline"));
1677 }
1678
1679 #[test]
1680 fn a_source_block_does_not_split_an_issue() {
1681 let content = concat!(
1682 "#+TITLE: x issues\n\n",
1683 "* TODO [#A] Real issue\n",
1684 ":PROPERTIES:\n",
1685 ":ID: x-aaaa\n",
1686 ":END:\n\n",
1687 "Quoted tracker:\n",
1688 "#+BEGIN_SRC org\n",
1689 "* TODO quoted\n",
1690 ":PROPERTIES:\n",
1691 ":ID: ghost-9999\n",
1692 ":END:\n",
1693 "#+END_SRC\n\n",
1694 "Still the same issue.\n",
1695 );
1696 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1697 assert_eq!(
1698 doc.headings.len(),
1699 1,
1700 "{:?}",
1701 doc.headings.iter().map(|h| &h.id).collect::<Vec<_>>()
1702 );
1703 assert_eq!(doc.headings[0].id, "x-aaaa");
1704 assert!(
1705 doc.headings[0].body.contains("* TODO quoted"),
1706 "{}",
1707 doc.headings[0].body
1708 );
1709 assert!(doc.headings[0].body.contains("Still the same issue."));
1710 }
1711
1712 #[test]
1713 fn org_ids_ignore_a_drawer_inside_a_block() {
1714 let text = concat!(
1715 "#+BEGIN_SRC org\n",
1716 "* TODO quoted\n",
1717 ":PROPERTIES:\n",
1718 ":ID: ghost-9999\n",
1719 ":END:\n",
1720 "#+END_SRC\n",
1721 "* TODO a title\n",
1722 ":PROPERTIES:\n",
1723 ":ID: atlas-1a2b\n",
1724 ":END:\n",
1725 );
1726 assert_eq!(ids(text), ["atlas-1a2b"]);
1727 }
1728
1729 #[test]
1730 fn a_timestamp_range_on_the_planning_line_parses() {
1731 let content = concat!(
1732 "#+TITLE: x issues\n\n",
1733 "* TODO [#A] Sprint\n",
1734 "SCHEDULED: <2026-09-01 Tue>--<2026-09-08 Tue> DEADLINE: <2026-09-15 Mon +1w>\n",
1735 ":PROPERTIES:\n",
1736 ":ID: x-aaaa\n",
1737 ":END:\n",
1738 );
1739 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1740 assert_eq!(doc.headings[0].id, "x-aaaa");
1741 assert_eq!(
1742 doc.headings[0].scheduled(),
1743 Some("<2026-09-01 Tue>--<2026-09-08 Tue>")
1744 );
1745 assert_eq!(doc.headings[0].deadline(), Some("<2026-09-15 Mon +1w>"));
1746 }
1747
1748 #[test]
1749 fn a_repeater_and_warning_on_the_planning_line_parse() {
1750 let content = concat!(
1751 "#+TITLE: x issues\n\n",
1752 "* TODO [#A] Weekly\n",
1753 "DEADLINE: <2026-09-01 Tue +1w -2d>\n",
1754 ":PROPERTIES:\n",
1755 ":ID: x-aaaa\n",
1756 ":END:\n",
1757 );
1758 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1759 assert_eq!(doc.headings[0].deadline(), Some("<2026-09-01 Tue +1w -2d>"));
1760 let rendered = doc.headings[0].render();
1761 assert!(
1762 rendered.contains("DEADLINE: <2026-09-01 Tue +1w -2d>"),
1763 "{rendered}"
1764 );
1765 }
1766
1767 #[test]
1768 fn a_logbook_before_properties_still_parses() {
1769 let content = concat!(
1770 "#+TITLE: x issues\n\n",
1771 "* TODO [#A] Clocked\n",
1772 ":LOGBOOK:\n",
1773 "CLOCK: [2026-08-18 Tue 10:00]--[2026-08-18 Tue 11:00] => 1:00\n",
1774 ":END:\n",
1775 ":PROPERTIES:\n",
1776 ":ID: x-aaaa\n",
1777 ":END:\n",
1778 );
1779 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1780 assert_eq!(doc.headings[0].id, "x-aaaa");
1781 assert_eq!(doc.headings[0].logbook.len(), 1);
1782 assert!(doc.headings[0].logbook[0].raw.is_some());
1783 }
1784
1785 #[test]
1786 fn other_drawers_at_the_drawer_site_do_not_hide_the_id() {
1787 let content = concat!(
1788 "#+TITLE: x issues\n\n",
1789 "* TODO [#A] Notes drawer\n",
1790 ":NOTES:\n",
1791 "hand written\n",
1792 ":END:\n",
1793 ":PROPERTIES:\n",
1794 ":ID: x-aaaa\n",
1795 ":END:\n\n",
1796 "Body stays.\n",
1797 );
1798 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1799 assert_eq!(doc.headings[0].id, "x-aaaa");
1800 assert_eq!(doc.headings[0].body, "Body stays.");
1801 let rendered = doc.headings[0].render();
1802 assert!(rendered.contains(":NOTES:"), "{rendered}");
1803 assert!(rendered.contains("hand written"), "{rendered}");
1804 }
1805
1806 #[test]
1807 fn a_comment_heading_is_not_an_issue() {
1808 let content = concat!(
1809 "#+TITLE: x issues\n\n",
1810 "* COMMENT Archived discussion\n",
1811 ":PROPERTIES:\n",
1812 ":ID: ghost-old\n",
1813 ":END:\n\n",
1814 "* TODO [#A] Live\n",
1815 ":PROPERTIES:\n",
1816 ":ID: x-aaaa\n",
1817 ":END:\n",
1818 );
1819 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1820 assert_eq!(doc.headings.len(), 1);
1821 assert_eq!(doc.headings[0].id, "x-aaaa");
1822 assert!(doc.preamble.contains("COMMENT Archived"));
1823 }
1824
1825 #[test]
1826 fn a_section_heading_round_trips() {
1827 let content = concat!(
1828 "#+TITLE: x issues\n\n",
1829 "* TODO [#A] First\n",
1830 ":PROPERTIES:\n",
1831 ":ID: x-aaaa\n",
1832 ":END:\n\n",
1833 "First body.\n\n",
1834 "* Notes\n",
1835 "Hand-written section.\n\n",
1836 "* TODO [#B] Second\n",
1837 ":PROPERTIES:\n",
1838 ":ID: x-bbbb\n",
1839 ":END:\n",
1840 );
1841 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1842 assert_eq!(doc.headings.len(), 2);
1843 assert_eq!(doc.headings[0].body, "First body.");
1844 assert!(doc.after[0].contains("* Notes"));
1845 assert!(doc.after[0].contains("Hand-written section."));
1846 let rendered = doc.headings[0].render();
1847 let mut file = String::from("#+TITLE: x issues\n\n");
1848 file.push_str(&rendered);
1849 file.push('\n');
1850 file.push_str(&doc.after[0]);
1851 file.push_str(&doc.headings[1].render());
1852 let again = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), &file).unwrap();
1853 assert_eq!(again.headings.len(), 2);
1854 assert!(again.after[0].contains("* Notes"));
1855 }
1856
1857 #[test]
1858 fn a_file_local_todo_keyword_is_an_issue() {
1859 let content = concat!(
1860 "#+TITLE: x issues\n",
1861 "#+TODO: TODO WAIT | DONE\n\n",
1862 "* WAIT [#B] Parked\n",
1863 ":PROPERTIES:\n",
1864 ":ID: x-aaaa\n",
1865 ":END:\n",
1866 );
1867 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1868 assert_eq!(doc.headings.len(), 1);
1869 assert_eq!(doc.headings[0].state, "WAIT");
1870 assert_eq!(doc.headings[0].id, "x-aaaa");
1871 }
1872
1873 #[test]
1874 fn a_statistics_cookie_is_not_the_title() {
1875 let content = concat!(
1876 "#+TITLE: x issues\n\n",
1877 "* TODO [#A] Break it down [2/5] :plan:\n",
1878 ":PROPERTIES:\n",
1879 ":ID: x-aaaa\n",
1880 ":END:\n",
1881 );
1882 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1883 assert_eq!(doc.headings[0].title, "Break it down");
1884 assert_eq!(doc.headings[0].statistics.as_deref(), Some("[2/5]"));
1885 assert_eq!(doc.headings[0].org_tags, vec!["plan"]);
1886 let line = doc.headings[0].render().lines().next().unwrap().to_string();
1887 assert!(line.contains("[2/5]"), "{line:?}");
1888 assert!(line.contains(":plan:"), "{line:?}");
1889 }
1890
1891 #[test]
1892 fn a_property_plus_appends() {
1893 let content = concat!(
1894 "#+TITLE: x issues\n\n",
1895 "* TODO [#A] Blocked\n",
1896 ":PROPERTIES:\n",
1897 ":ID: x-aaaa\n",
1898 ":BLOCKED_BY: x-bbbb\n",
1899 ":BLOCKED_BY+: x-cccc\n",
1900 ":END:\n",
1901 );
1902 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1903 assert_eq!(doc.headings[0].blocked_by(), vec!["x-bbbb", "x-cccc"]);
1904 }
1905
1906 #[test]
1907 fn babel_results_do_not_split_an_issue_or_define_an_id() {
1908 let content = concat!(
1909 "#+TITLE: x issues\n\n",
1910 "* TODO [#A] Real issue\n",
1911 ":PROPERTIES:\n",
1912 ":ID: x-aaaa\n",
1913 ":END:\n\n",
1914 "#+NAME: dump\n",
1915 "#+HEADER: :results raw\n",
1916 "#+BEGIN_SRC python :results raw\n",
1917 "print('* TODO dumped')\n",
1918 "#+END_SRC\n\n",
1919 "#+RESULTS:\n",
1920 "* TODO dumped\n",
1921 ":PROPERTIES:\n",
1922 ":ID: ghost-9999\n",
1923 ":END:\n\n",
1924 "Still the same issue.\n\n",
1925 "* TODO [#B] Next\n",
1926 ":PROPERTIES:\n",
1927 ":ID: x-bbbb\n",
1928 ":END:\n",
1929 );
1930 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1931 assert_eq!(
1932 doc.headings
1933 .iter()
1934 .map(|h| h.id.as_str())
1935 .collect::<Vec<_>>(),
1936 ["x-aaaa", "x-bbbb"]
1937 );
1938 assert!(
1939 doc.headings[0].body.contains("#+RESULTS:"),
1940 "{}",
1941 doc.headings[0].body
1942 );
1943 assert!(
1944 doc.headings[0].body.contains("* TODO dumped"),
1945 "{}",
1946 doc.headings[0].body
1947 );
1948 assert!(doc.headings[0].body.contains("Still the same issue."));
1949 assert_eq!(ids(content), ["x-aaaa", "x-bbbb"]);
1950 }
1951
1952 #[test]
1953 fn a_babel_call_with_results_drawer_stays_in_the_body() {
1954 let content = concat!(
1955 "#+TITLE: x issues\n\n",
1956 "* TODO [#A] Calls a named block\n",
1957 ":PROPERTIES:\n",
1958 ":ID: x-aaaa\n",
1959 ":END:\n\n",
1960 "#+CALL: plot(x=1) :results drawer\n",
1961 "#+RESULTS:\n",
1962 ":RESULTS:\n",
1963 "* TODO not an issue\n",
1964 ":END:\n",
1965 );
1966 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1967 assert_eq!(doc.headings.len(), 1);
1968 assert!(doc.headings[0].body.contains("#+CALL: plot"));
1969 assert!(doc.headings[0].body.contains("* TODO not an issue"));
1970 assert_eq!(ids(content), ["x-aaaa"]);
1971 }
1972 #[test]
1974 fn the_same_project_subject_and_seed_mint_the_same_id() {
1975 crate::process_env::override_var(ID_SEED_ENV, Some("99"));
1976 let a = generate_id("proj", "write the thing", &[], 4).unwrap();
1977 let b = generate_id("proj", "write the thing", &[], 4).unwrap();
1978 assert_eq!(a, b, "the same inputs minted two different ids");
1979
1980 let c = generate_id("proj", "a different thing", &[], 4).unwrap();
1983 assert_ne!(a, c);
1984
1985 let d = generate_id("other", "write the thing", &[], 4).unwrap();
1987 assert_ne!(a, d);
1988 crate::process_env::clear_override(ID_SEED_ENV);
1989 }
1990
1991 #[test]
1994 fn two_issues_with_one_subject_do_not_share_an_id() {
1995 crate::process_env::override_var(ID_SEED_ENV, Some("7"));
1996 let first = generate_id("proj", "same title", &[], 4).unwrap();
1997 let second = generate_id("proj", "same title", std::slice::from_ref(&first), 4).unwrap();
1998 assert_ne!(first, second);
1999 crate::process_env::clear_override(ID_SEED_ENV);
2000 }
2001
2002 #[test]
2004 fn a_different_seed_mints_a_different_id() {
2005 crate::process_env::override_var(ID_SEED_ENV, Some("1"));
2006 let one = generate_id("proj", "subject", &[], 4).unwrap();
2007 crate::process_env::override_var(ID_SEED_ENV, Some("2"));
2008 let two = generate_id("proj", "subject", &[], 4).unwrap();
2009 crate::process_env::clear_override(ID_SEED_ENV);
2010 assert_ne!(one, two);
2011 }
2012}