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};
13
14use crate::config::Layout;
15use crate::model::{IssueHeading, LogEntry, TODO_HEADER, parse_log_line, today_inactive_bracket};
16use crate::org::{
17 OrgScan, TagSettings, ensure_org_preamble, is_headline, is_issue_headline, is_planning_line,
18 is_top_level_headline, opens_a_drawer, parse_headline_bits, parse_planning_line,
19 property_key_and_append, split_statistics_cookies, tag_settings_from_preamble,
20 todo_keywords_from_lines,
21};
22
23static PROCESS_LOCKS: OnceLock<Mutex<HashMap<PathBuf, Arc<Mutex<()>>>>> = OnceLock::new();
27static WRITE_TMP_SEQ: AtomicU64 = AtomicU64::new(0);
28
29const ID_ALPHABET: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz";
30
31struct CrossProcessLock {
32 file: fs::File,
33}
34
35impl CrossProcessLock {
36 fn acquire(path: &Path) -> Result<Self> {
37 let lock_path = issues_lock_path(path);
38 if let Some(parent) = lock_path.parent() {
39 fs::create_dir_all(parent)
40 .with_context(|| format!("create lock parent {}", parent.display()))?;
41 }
42 let file = fs::OpenOptions::new()
43 .create(true)
44 .read(true)
45 .append(true)
46 .open(&lock_path)
47 .with_context(|| format!("open lock {}", lock_path.display()))?;
48 file.lock_exclusive()
49 .with_context(|| format!("lock {}", lock_path.display()))?;
50 Ok(Self { file })
51 }
52}
53
54impl Drop for CrossProcessLock {
55 fn drop(&mut self) {
56 let _ = fs2::FileExt::unlock(&self.file);
57 }
58}
59
60fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
63 use std::io::Write as _;
64 let mut file = fs::File::create(path)?;
65 file.write_all(bytes)?;
66 file.sync_all()
67}
68
69pub fn replace_file_atomically(path: &Path, body: &str) -> Result<()> {
80 let parent = path
81 .parent()
82 .filter(|p| !p.as_os_str().is_empty())
83 .map(Path::to_path_buf)
84 .unwrap_or_else(|| PathBuf::from("."));
85 fs::create_dir_all(&parent).with_context(|| format!("create {}", parent.display()))?;
86 let seq = WRITE_TMP_SEQ.fetch_add(1, Ordering::Relaxed);
87 let base = path
88 .file_name()
89 .and_then(|s| s.to_str())
90 .unwrap_or("output");
91 let tmp = parent.join(format!(".{}.tmp.{}-{}", base, std::process::id(), seq));
92 if let Err(e) = write_synced(&tmp, body.as_bytes()) {
93 let _ = fs::remove_file(&tmp);
94 return Err(e)
95 .with_context(|| format!("write temp {}", tmp.display()))
96 .map_err(crate::error::Error::from);
97 }
98 if let Err(e) = fs::rename(&tmp, path) {
99 let _ = fs::remove_file(&tmp);
100 return Err(e)
101 .with_context(|| format!("rename {} -> {}", tmp.display(), path.display()))
102 .map_err(crate::error::Error::from);
103 }
104 Ok(())
105}
106
107fn issues_lock_path(path: &Path) -> PathBuf {
108 let mut s = path.as_os_str().to_owned();
109 s.push(".lock");
110 PathBuf::from(s)
111}
112
113fn process_mutex_for(path: &Path) -> Arc<Mutex<()>> {
114 let key = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
115 let mut map = PROCESS_LOCKS
116 .get_or_init(|| Mutex::new(HashMap::new()))
117 .lock()
118 .unwrap_or_else(|p| p.into_inner());
119 map.entry(key)
120 .or_insert_with(|| Arc::new(Mutex::new(())))
121 .clone()
122}
123
124pub fn with_issues_lock<R, F>(path: &Path, f: F) -> Result<R>
134where
135 F: FnOnce() -> Result<R>,
136{
137 let mutex = process_mutex_for(path);
138 let _proc = mutex.lock().unwrap_or_else(|p| p.into_inner());
139 let _cross = CrossProcessLock::acquire(path)?;
140 f()
141}
142
143pub fn with_issues_locks<R, F>(paths: &[&Path], f: F) -> Result<R>
151where
152 F: FnOnce() -> Result<R>,
153{
154 let mut keys: Vec<PathBuf> = paths.iter().map(|p| (*p).to_path_buf()).collect();
155 keys.sort();
156 keys.dedup();
157 let mutexes: Vec<Arc<Mutex<()>>> = keys.iter().map(|k| process_mutex_for(k)).collect();
158 let mut proc_guards = Vec::with_capacity(mutexes.len());
159 let mut cross_guards = Vec::with_capacity(keys.len());
160 for (key, mutex) in keys.iter().zip(mutexes.iter()) {
161 proc_guards.push(mutex.lock().unwrap_or_else(|p| p.into_inner()));
162 cross_guards.push(CrossProcessLock::acquire(key)?);
163 }
164 f()
165}
166
167#[derive(Debug, Clone)]
169pub struct IssueDoc {
170 pub project: String,
172 pub path: PathBuf,
174 pub preamble: String,
176 pub tag_settings: TagSettings,
178 pub headings: Vec<IssueHeading>,
180 after: Vec<String>,
184}
185
186impl IssueDoc {
187 pub fn empty(project: &str, path: PathBuf) -> Self {
189 let preamble = default_preamble(project);
190 IssueDoc {
191 project: project.to_string(),
192 path,
193 tag_settings: tag_settings_from_preamble(&preamble),
194 preamble,
195 headings: Vec::new(),
196 after: Vec::new(),
197 }
198 }
199
200 pub fn parse_file(project: &str, path: &Path) -> Result<Self> {
206 if !path.exists() {
207 return Ok(Self::empty(project, path.to_path_buf()));
208 }
209 let content =
210 fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
211 Self::parse(project, path.to_path_buf(), &content)
212 }
213
214 pub fn parse(project: &str, path: PathBuf, content: &str) -> Result<Self> {
222 let lines: Vec<&str> = content.lines().collect();
223 let preamble_end = {
224 let mut nest = OrgScan::new();
225 lines
226 .iter()
227 .position(|line| {
228 if nest.observe(line) {
229 return false;
230 }
231 is_headline(line)
232 })
233 .unwrap_or(lines.len())
234 };
235 let raw_preamble = if preamble_end == 0 {
236 String::new()
237 } else {
238 lines[..preamble_end].join("\n").trim_end().to_string()
239 };
240 let settings = crate::org::merge_setupfile_settings(&raw_preamble, path.parent());
241 let mut keyword_src = settings.clone();
242 keyword_src.push('\n');
243 keyword_src.push_str(content);
244 let keyword_lines: Vec<&str> = keyword_src.lines().collect();
245 let keywords = todo_keywords_from_lines(&keyword_lines);
246 let mut nest = OrgScan::new();
247 let first_heading = lines
248 .iter()
249 .enumerate()
250 .position(|(i, line)| {
251 if nest.observe(line) {
252 return false;
253 }
254 is_vissue_headline(&lines, i, &keywords)
255 })
256 .unwrap_or(lines.len());
257 let preamble = if first_heading == 0 {
258 default_preamble(project)
259 } else {
260 lines[..first_heading].join("\n").trim_end().to_string()
261 };
262 let mut headings = Vec::new();
263 let mut after = Vec::new();
264 let mut i = first_heading;
265 while i < lines.len() {
266 if !is_vissue_headline(&lines, i, &keywords) {
267 i += 1;
268 continue;
269 }
270 let (heading, body_end) = parse_heading(
271 &lines,
272 i,
273 &keywords,
274 crate::org::priorities_from_preamble(&settings).default,
275 )
276 .with_context(|| format!("at {}:{}", path.display(), i + 1))?;
277 headings.push(heading);
278 i = body_end;
279 let inter_start = i;
280 let mut nest = OrgScan::new();
281 while i < lines.len() {
282 if !nest.observe(lines[i]) && is_vissue_headline(&lines, i, &keywords) {
283 break;
284 }
285 i += 1;
286 }
287 let raw = lines[inter_start..i].join("\n");
288 after.push(if raw.trim().is_empty() {
289 String::new()
290 } else {
291 raw.trim_start_matches('\n').to_string()
292 });
293 }
294 let parent = path.parent().map(std::path::Path::to_path_buf);
295 Ok(IssueDoc {
296 project: project.to_string(),
297 path,
298 tag_settings: tag_settings_from_preamble(&crate::org::merge_setupfile_settings(
299 &preamble,
300 parent.as_deref(),
301 )),
302 preamble,
303 headings,
304 after,
305 })
306 }
307
308 pub fn render_string(&self) -> String {
310 let mut out = String::new();
311 let preamble = if self.preamble.trim().is_empty() {
312 default_preamble(&self.project)
313 } else {
314 ensure_org_preamble(&self.preamble, &self.project)
315 };
316 out.push_str(preamble.trim_end());
317 out.push_str("\n\n");
318 for (i, h) in self.headings.iter().enumerate() {
319 out.push_str(&h.render());
320 out.push('\n');
321 if let Some(extra) = self.after.get(i)
322 && !extra.is_empty()
323 {
324 out.push_str(extra);
325 if !extra.ends_with('\n') {
326 out.push('\n');
327 }
328 }
329 }
330 out
331 }
332
333 pub fn write(&self) -> Result<()> {
341 if let Some(parent) = self.path.parent() {
342 fs::create_dir_all(parent)?;
343 }
344 let out = self.render_string();
345 let seq = WRITE_TMP_SEQ.fetch_add(1, Ordering::Relaxed);
348 let nanos = SystemTime::now()
349 .duration_since(UNIX_EPOCH)
350 .map(|d| d.as_nanos())
351 .unwrap_or(0);
352 let base = self
353 .path
354 .file_name()
355 .and_then(|s| s.to_str())
356 .unwrap_or("issues.org");
357 let tmp = self
358 .path
359 .parent()
360 .unwrap_or_else(|| Path::new("."))
361 .join(format!(
362 ".{}.tmp.{}-{}-{}",
363 base,
364 std::process::id(),
365 nanos,
366 seq
367 ));
368 if let Err(e) = write_synced(&tmp, out.as_bytes()) {
372 let _ = fs::remove_file(&tmp);
373 return Err(e)
374 .with_context(|| format!("write temp {}", tmp.display()))
375 .map_err(crate::error::Error::from);
376 }
377 if let Err(e) = fs::rename(&tmp, &self.path) {
378 let _ = fs::remove_file(&tmp);
379 return Err(e)
380 .with_context(|| format!("rename {} -> {}", tmp.display(), self.path.display()))
381 .map_err(crate::error::Error::from);
382 }
383 self.announce_write();
384 Ok(())
385 }
386
387 fn announce_write(&self) {
391 if !crate::events::enabled() {
392 return;
393 }
394 let Some(dir) = self.path.parent().and_then(|p| p.parent()) else {
395 return;
396 };
397 let _ = crate::events::emit_issues_write(dir, &self.project, &self.path);
398 let _ = crate::events::ensure_gitignore_hint(dir);
399 }
400
401 pub fn priority_spec(&self) -> crate::org::PrioritySpec {
403 crate::org::priorities_from_preamble(&self.merged_inbuffer_settings())
404 }
405
406 pub fn priorities_are_named(&self) -> bool {
408 crate::org::preamble_has_keyword(&self.merged_inbuffer_settings(), "PRIORITIES")
409 }
410
411 pub fn default_create_priority(&self, cfg_default: char) -> char {
413 if self.priorities_are_named() {
414 self.priority_spec().default
415 } else {
416 cfg_default
417 }
418 }
419
420 fn merged_inbuffer_settings(&self) -> String {
421 crate::org::merge_setupfile_settings(&self.preamble, self.path.parent())
422 }
423
424 pub fn known_ids(&self) -> Vec<String> {
426 self.headings.iter().map(|h| h.id.clone()).collect()
427 }
428
429 pub fn upsert(&mut self, heading: IssueHeading) {
431 if let Some(slot) = self.headings.iter_mut().find(|h| h.id == heading.id) {
432 *slot = heading;
433 } else {
434 self.headings.push(heading);
435 self.after.push(String::new());
436 }
437 }
438
439 pub fn remove(&mut self, id: &str) -> Option<IssueHeading> {
441 let idx = self.headings.iter().position(|h| h.id == id)?;
442 let heading = self.headings.remove(idx);
443 let extra = if idx < self.after.len() {
444 self.after.remove(idx)
445 } else {
446 String::new()
447 };
448 if !extra.trim().is_empty() {
449 if idx == 0 {
450 if !self.preamble.is_empty() && !self.preamble.ends_with('\n') {
451 self.preamble.push('\n');
452 }
453 if !self.preamble.is_empty() {
454 self.preamble.push('\n');
455 }
456 self.preamble.push_str(&extra);
457 } else if let Some(prev) = self.after.get_mut(idx - 1) {
458 if !prev.is_empty() && !prev.ends_with('\n') {
459 prev.push('\n');
460 }
461 prev.push_str(&extra);
462 }
463 }
464 Some(heading)
465 }
466}
467
468fn is_vissue_headline(lines: &[&str], i: usize, keywords: &[String]) -> bool {
469 if !is_issue_headline(lines[i], keywords) {
470 return false;
471 }
472 peek_heading_id(lines, i).is_none_or(|id| !crate::org::is_gcal_event_id(&id))
473}
474
475fn peek_heading_id(lines: &[&str], start: usize) -> Option<String> {
476 let mut i = start + 1;
477 while i < lines.len() && !parse_planning_line(lines[i]).is_empty() {
478 i += 1;
479 }
480 while i < lines.len() {
481 let trimmed = lines[i].trim();
482 if trimmed.is_empty() {
483 i += 1;
484 continue;
485 }
486 if !opens_a_drawer(trimmed) {
487 break;
488 }
489 if trimmed.eq_ignore_ascii_case(":PROPERTIES:") {
490 i += 1;
491 while i < lines.len() && !lines[i].trim().eq_ignore_ascii_case(":END:") {
492 let line = lines[i].trim();
493 if let Some(rest) = line.strip_prefix(':')
494 && let Some((key, value)) = rest.split_once(':')
495 {
496 let (key, _) = property_key_and_append(key.trim());
497 if key.eq_ignore_ascii_case("ID") {
498 let value = value.trim();
499 if !value.is_empty() {
500 return Some(value.to_string());
501 }
502 }
503 }
504 i += 1;
505 }
506 return None;
507 }
508 i += 1;
509 while i < lines.len() && !lines[i].trim().eq_ignore_ascii_case(":END:") {
510 i += 1;
511 }
512 if i < lines.len() {
513 i += 1;
514 }
515 }
516 None
517}
518
519fn parse_heading(
520 lines: &[&str],
521 start: usize,
522 keywords: &[String],
523 default_priority: char,
524) -> Result<(IssueHeading, usize)> {
525 let header = lines[start];
526 let stripped = header
527 .strip_prefix("* ")
528 .ok_or_else(|| anyhow!("not a heading"))?;
529 let bits = parse_headline_bits(stripped, keywords);
530 let state = bits
531 .keyword
532 .ok_or_else(|| anyhow!("not an issue heading"))?
533 .to_string();
534 let priority = bits.priority.unwrap_or(default_priority);
535 let (title_and_cookies, org_tags) = crate::model::split_headline_tags(bits.rest);
536 let (title, statistics) = split_statistics_cookies(&title_and_cookies);
537
538 let mut properties = BTreeMap::new();
539 let mut property_order = Vec::new();
540 let mut logbook: Vec<LogEntry> = Vec::new();
541 let mut extra_drawers: Vec<String> = Vec::new();
542 let mut i = start + 1;
543
544 while i < lines.len() {
548 let found = parse_planning_line(lines[i]);
549 if found.is_empty() {
550 break;
551 }
552 for (key, value) in found {
553 if !property_order.contains(&key) {
554 property_order.push(key.clone());
555 }
556 properties.insert(key, value);
557 }
558 i += 1;
559 }
560
561 while i < lines.len() {
562 let trimmed = lines[i].trim();
563 if trimmed.is_empty() {
564 i += 1;
565 continue;
566 }
567 if !opens_a_drawer(trimmed) {
568 break;
569 }
570 if trimmed.eq_ignore_ascii_case(":PROPERTIES:") {
571 i += 1;
572 while i < lines.len() && !lines[i].trim().eq_ignore_ascii_case(":END:") {
573 let line = lines[i].trim();
574 if let Some(rest) = line.strip_prefix(':')
575 && let Some(idx) = rest.find(':')
576 {
577 let raw_key = &rest[..idx];
578 let (key, append) = property_key_and_append(raw_key);
579 let val = rest[idx + 1..].trim().to_string();
580 if append {
581 properties
582 .entry(key.to_string())
583 .and_modify(|existing| {
584 if !val.is_empty() {
585 if !existing.is_empty() {
586 existing.push(' ');
587 }
588 existing.push_str(&val);
589 }
590 })
591 .or_insert(val);
592 } else {
593 properties.insert(key.to_string(), val);
594 }
595 if !property_order.iter().any(|k| k == key) {
596 property_order.push(key.to_string());
597 }
598 }
599 i += 1;
600 }
601 if i < lines.len() {
602 i += 1;
603 }
604 continue;
605 }
606 if trimmed.eq_ignore_ascii_case(":LOGBOOK:") {
607 i += 1;
608 while i < lines.len() && !lines[i].trim().eq_ignore_ascii_case(":END:") {
609 if !lines[i].trim().is_empty() {
610 logbook.push(parse_log_line(lines[i]));
611 }
612 i += 1;
613 }
614 if i < lines.len() {
615 i += 1;
616 }
617 continue;
618 }
619 let drawer_start = i;
620 i += 1;
621 while i < lines.len() && !lines[i].trim().eq_ignore_ascii_case(":END:") {
622 i += 1;
623 }
624 if i < lines.len() {
625 i += 1;
626 }
627 let mut drawer = lines[drawer_start..i].join("\n");
628 drawer.push('\n');
629 extra_drawers.push(drawer);
630 }
631
632 let body_start = i;
633 let mut body_end = body_start;
634 let mut nest = OrgScan::new();
635 while body_end < lines.len() {
636 if !nest.observe(lines[body_end]) && is_top_level_headline(lines[body_end]) {
637 break;
638 }
639 body_end += 1;
640 }
641 let body = lines[body_start..body_end]
642 .join("\n")
643 .trim_matches('\n')
644 .trim_end()
645 .to_string();
646
647 if let Some(legacy) = properties.remove(crate::model::LEGACY_TAGS_PROPERTY) {
651 property_order.retain(|key| key != crate::model::LEGACY_TAGS_PROPERTY);
652 properties
653 .entry(crate::model::TAGS_PROPERTY.to_string())
654 .or_insert(legacy);
655 }
656
657 let id = properties
658 .get("ID")
659 .cloned()
660 .ok_or_else(|| anyhow!(":ID: property missing"))?;
661
662 Ok((
663 IssueHeading {
664 id,
665 title,
666 state,
667 priority,
668 properties,
669 org_tags,
670 statistics,
671 property_order,
672 extra_drawers,
673 body,
674 logbook,
675 line_start: start + 1,
676 line_end: if body_end == 0 { 1 } else { body_end },
677 },
678 body_end,
679 ))
680}
681
682pub fn default_preamble(project: &str) -> String {
688 format!(
689 "#+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{}",
690 crate::org::PROTOCOL_VERSION,
691 crate::org::HOUSE_TAGS_LINES[0],
692 crate::org::HOUSE_TAGS_LINES[1],
693 crate::org::HOUSE_PRIORITIES_LINE,
694 today_inactive_bracket(),
695 TODO_HEADER
696 )
697}
698
699pub fn list_projects(layout: &Layout) -> Result<Vec<String>> {
705 let dir = layout.projects_dir();
706 if !dir.exists() {
707 return Ok(Vec::new());
708 }
709 let mut projects = Vec::new();
710 for entry in fs::read_dir(&dir).with_context(|| format!("read dir {}", dir.display()))? {
711 let entry = entry?;
712 let path = entry.path();
713 if path.is_dir()
714 && path.join("issues.org").exists()
715 && let Some(name) = path.file_name().and_then(|n| n.to_str())
716 {
717 projects.push(name.to_string());
718 }
719 }
720 projects.sort();
721 Ok(projects)
722}
723
724pub fn resolve_existing_project_case(layout: &Layout, project: &str) -> Result<String> {
731 if project.is_empty() {
732 return Ok(project.to_string());
733 }
734 if layout.project_issues_path(project).exists() {
735 return Ok(project.to_string());
736 }
737 let project_lower = project.to_lowercase();
738 let matches: Vec<String> = list_projects(layout)?
739 .into_iter()
740 .filter(|candidate| candidate.to_lowercase() == project_lower)
741 .collect();
742 match matches.as_slice() {
743 [] => Ok(project.to_string()),
744 [canonical] => Ok(canonical.clone()),
745 _ => Err(anyhow!(
746 "project {project:?} is ambiguous; case-insensitive matches: {}",
747 matches.join(", ")
748 )
749 .into()),
750 }
751}
752
753pub fn project_selected(project: &str, filter: Option<&str>) -> bool {
760 match filter {
761 None => true,
762 Some(p) => project.eq_ignore_ascii_case(p),
763 }
764}
765
766pub fn find_by_id(layout: &Layout, id: &str) -> Result<Option<(IssueHeading, PathBuf, String)>> {
772 for project in list_projects(layout)? {
773 let path = layout.project_issues_path(&project);
774 let doc = IssueDoc::parse_file(&project, &path)?;
775 for h in doc.headings {
776 if h.id == id {
777 return Ok(Some((h, path, project)));
778 }
779 }
780 }
781 Ok(None)
782}
783
784pub fn load_all(layout: &Layout) -> Result<Vec<(String, IssueHeading)>> {
790 let mut all = Vec::new();
791 for project in list_projects(layout)? {
792 let path = layout.project_issues_path(&project);
793 let doc = IssueDoc::parse_file(&project, &path)?;
794 for h in doc.headings {
795 all.push((project.clone(), h));
796 }
797 }
798 Ok(all)
799}
800
801pub fn generate_id(project: &str, existing: &[String], length: usize) -> Result<String> {
812 let len = length.max(2);
813 let taken: std::collections::HashSet<&str> = existing.iter().map(String::as_str).collect();
814 let base = SystemTime::now()
815 .duration_since(UNIX_EPOCH)
816 .map(|d| d.as_nanos())
817 .unwrap_or(1);
818 let attempts = 36usize
821 .checked_pow(len as u32)
822 .map(|space| space.saturating_mul(2))
823 .unwrap_or(usize::MAX)
824 .min(2_000_000);
825 for counter in 0..attempts as u128 {
826 let mut n = base
827 .wrapping_add(counter.wrapping_mul(17))
828 .wrapping_mul(2654435761);
829 let mut suffix = String::new();
830 for _ in 0..len {
831 suffix.push(ID_ALPHABET[(n as usize) % 36] as char);
832 n /= 36;
833 }
834 let id = format!("{}-{}", project, suffix);
835 if !taken.contains(id.as_str()) {
836 return Ok(id);
837 }
838 }
839 Err(anyhow!(
840 "no free id left for {project:?} at id_length = {len}; \
841 raise `id_length` under [issues] in vissue.toml"
842 )
843 .into())
844}
845
846pub fn detect_project_from_ctx(start: &Path) -> Option<String> {
848 let mut dir = start.canonicalize().ok()?;
849 loop {
850 let candidate = dir.join(".project-ctx.toml");
851 if candidate.exists()
852 && let Ok(text) = fs::read_to_string(&candidate)
853 && let Ok(value) = text.parse::<toml::Value>()
854 && let Some(name) = value
855 .get("project")
856 .and_then(|p| p.get("name"))
857 .and_then(|n| n.as_str())
858 {
859 return Some(name.to_string());
860 }
861 if !dir.pop() {
862 break;
863 }
864 }
865 None
866}
867
868pub fn find_org_ids(
879 layout: &Layout,
880 wanted: &std::collections::HashSet<String>,
881) -> Result<std::collections::HashSet<String>> {
882 let mut found = std::collections::HashSet::new();
883 if wanted.is_empty() {
884 return Ok(found);
885 }
886 let dir = layout.projects_dir();
887 if !dir.exists() {
888 return Ok(found);
889 }
890 for entry in walkdir::WalkDir::new(&dir)
891 .into_iter()
892 .filter_entry(|e| !is_skipped_dir(e))
893 {
894 let entry = match entry {
895 Ok(e) => e,
896 Err(_) => continue,
897 };
898 if !entry.file_type().is_file()
899 || entry.path().extension().and_then(|s| s.to_str()) != Some("org")
900 {
901 continue;
902 }
903 let content = fs::read_to_string(entry.path())
904 .with_context(|| format!("read {}", entry.path().display()))?;
905 for id in org_ids(&content) {
906 if crate::org::is_gcal_event_id(id) {
907 continue;
908 }
909 if wanted.contains(id) {
910 found.insert(id.to_string());
911 if found.len() == wanted.len() {
912 return Ok(found);
913 }
914 }
915 }
916 }
917 Ok(found)
918}
919
920pub fn collect_org_ids(layout: &Layout) -> Result<std::collections::HashSet<String>> {
926 let mut ids = std::collections::HashSet::new();
927 let dir = layout.projects_dir();
928 if !dir.exists() {
929 return Ok(ids);
930 }
931 for entry in walkdir::WalkDir::new(&dir)
932 .into_iter()
933 .filter_entry(|e| !is_skipped_dir(e))
934 {
935 let entry = match entry {
936 Ok(e) => e,
937 Err(_) => continue,
938 };
939 if !entry.file_type().is_file()
940 || entry.path().extension().and_then(|s| s.to_str()) != Some("org")
941 {
942 continue;
943 }
944 let content = fs::read_to_string(entry.path())
945 .with_context(|| format!("read {}", entry.path().display()))?;
946 for id in org_ids(&content) {
947 if !crate::org::is_gcal_event_id(id) {
948 ids.insert(id.to_string());
949 }
950 }
951 }
952 Ok(ids)
953}
954
955fn is_skipped_dir(entry: &walkdir::DirEntry) -> bool {
956 if !entry.file_type().is_dir() {
957 return false;
958 }
959 let name = entry.file_name().to_string_lossy();
960 matches!(
961 name.as_ref(),
962 "node_modules" | "target" | ".git" | ".cache" | "build"
963 )
964}
965
966fn org_id_property_value(line: &str) -> Option<&str> {
967 let value = line.trim_start().strip_prefix(":ID:")?.trim();
968 if value.is_empty() { None } else { Some(value) }
969}
970
971fn org_ids(content: &str) -> impl Iterator<Item = &str> {
983 let mut at_drawer_site = true;
985 let mut in_drawer = false;
986 let mut drawer_is_properties = false;
987 let mut under_headline = false;
990 let mut nest = OrgScan::new();
991
992 content.lines().filter_map(move |line| {
993 let trimmed = line.trim();
994
995 if in_drawer {
996 if trimmed.eq_ignore_ascii_case(":END:") {
997 in_drawer = false;
998 drawer_is_properties = false;
999 at_drawer_site = true;
1002 return None;
1003 }
1004 if drawer_is_properties {
1005 return org_id_property_value(line);
1006 }
1007 return None;
1008 }
1009
1010 if nest.observe(line) {
1013 at_drawer_site = false;
1014 under_headline = false;
1015 return None;
1016 }
1017
1018 if is_headline(line) {
1019 at_drawer_site = true;
1020 under_headline = true;
1021 return None;
1022 }
1023 let planning_may_start_here = under_headline;
1024 under_headline = false;
1025
1026 if trimmed.is_empty() || trimmed.starts_with("#+") {
1029 return None;
1030 }
1031 if at_drawer_site {
1032 if opens_a_drawer(trimmed) {
1033 in_drawer = true;
1034 drawer_is_properties = trimmed.eq_ignore_ascii_case(":PROPERTIES:");
1035 return None;
1036 }
1037 if planning_may_start_here && is_planning_line(trimmed) {
1038 return None;
1039 }
1040 }
1041 at_drawer_site = false;
1043 None
1044 })
1045}
1046
1047#[cfg(test)]
1048mod tests {
1049 use super::*;
1050 use crate::config::DEFAULT_PREFIX;
1051
1052 fn sample_heading() -> IssueHeading {
1053 let mut props = BTreeMap::new();
1054 props.insert("ID".into(), "sample-abc1".into());
1055 props.insert("CREATED".into(), "[2026-04-25 Sat]".into());
1056 props.insert("TYPE".into(), "feature".into());
1057 IssueHeading {
1058 id: "sample-abc1".into(),
1059 title: "Add a thing".into(),
1060 state: "TODO".into(),
1061 priority: 'A',
1062 properties: props,
1063 org_tags: Vec::new(),
1064 statistics: None,
1065 property_order: vec!["ID".into(), "CREATED".into(), "TYPE".into()],
1066 extra_drawers: Vec::new(),
1067 body: "Some body lines.\nWith multiple lines.".into(),
1068 logbook: Vec::new(),
1069 line_start: 4,
1070 line_end: 12,
1071 }
1072 }
1073
1074 #[test]
1075 fn render_then_parse_preserves_the_heading() {
1076 let mut content = String::from("#+TITLE: sample issues\n");
1077 content.push_str(TODO_HEADER);
1078 content.push_str("\n\n");
1079 content.push_str(&sample_heading().render());
1080 let parsed = IssueDoc::parse("sample", PathBuf::from("/tmp/x.org"), &content).unwrap();
1081 let h = &parsed.headings[0];
1082 let original = sample_heading();
1083 assert_eq!(h.id, original.id);
1084 assert_eq!(h.title, original.title);
1085 assert_eq!(h.state, original.state);
1086 assert_eq!(h.priority, original.priority);
1087 assert_eq!(h.body, original.body);
1088 assert_eq!(
1089 crate::props::get(&h.properties, crate::props::TYPE),
1090 crate::props::get(&original.properties, crate::props::TYPE)
1091 );
1092 }
1093
1094 #[test]
1095 fn heading_without_a_priority_cookie_defaults_to_c() {
1096 let content =
1097 "#+TITLE: x issues\n\n* TODO Just a title\n:PROPERTIES:\n:ID: x-aaaa\n:END:\n";
1098 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1099 assert_eq!(doc.headings[0].priority, 'C');
1100 assert_eq!(doc.headings[0].title, "Just a title");
1101 }
1102
1103 #[test]
1104 fn a_multibyte_priority_cookie_parses_instead_of_panicking() {
1105 let content = "#+TITLE: x issues\n\n* TODO [#\u{2192}] Hand edited\n:PROPERTIES:\n:ID: x-aaaa\n:END:\n";
1106 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1107 assert_eq!(doc.headings[0].priority, '\u{2192}');
1108 assert_eq!(doc.headings[0].title, "Hand edited");
1109 }
1110
1111 #[test]
1112 fn a_title_opening_with_a_bracket_keeps_its_text() {
1113 let content = "#+TITLE: x issues\n\n* TODO [#not a cookie] stays\n:PROPERTIES:\n:ID: x-bbbb\n:END:\n";
1114 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1115 assert_eq!(doc.headings[0].priority, 'C');
1116 assert_eq!(doc.headings[0].title, "[#not a cookie] stays");
1117 }
1118
1119 #[test]
1120 fn an_org_planning_line_parses_instead_of_hiding_the_drawer() {
1121 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";
1125 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1126 let h = &doc.headings[0];
1127 assert_eq!(h.id, "x-aaaa");
1128 assert_eq!(h.deadline(), Some("<2026-09-01 Tue>"));
1129 assert_eq!(h.scheduled(), Some("<2026-09-05 Sat>"));
1130 assert_eq!(
1131 h.properties.get("CLOSED").map(String::as_str),
1132 Some("[2026-08-14 Fri 03:33]")
1133 );
1134 assert_eq!(h.body, "Body stays body.");
1135 }
1136
1137 #[test]
1138 fn a_planning_line_round_trips_in_orgs_own_order() {
1139 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";
1140 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1141 let rendered = doc.headings[0].render();
1142 assert!(
1143 rendered.contains(
1144 "\nCLOSED: [2026-08-14 Fri 03:33] SCHEDULED: <2026-09-05 Sat> DEADLINE: <2026-09-01 Tue>\n"
1145 ),
1146 "{rendered}"
1147 );
1148 assert!(!rendered.contains(":DEADLINE:"), "{rendered}");
1149 }
1150
1151 #[test]
1152 fn a_legacy_date_property_is_promoted_to_a_planning_line() {
1153 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";
1156 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1157 assert_eq!(doc.headings[0].deadline(), Some("<2026-09-01 Tue>"));
1158 let rendered = doc.headings[0].render();
1159 assert!(
1160 rendered.contains("\nDEADLINE: <2026-09-01 Tue>\n"),
1161 "{rendered}"
1162 );
1163 assert!(!rendered.contains(":DEADLINE:"), "{rendered}");
1164 }
1165
1166 #[test]
1167 fn a_line_that_only_looks_like_planning_is_left_as_body() {
1168 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";
1169 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1170 assert_eq!(doc.headings[0].deadline(), None);
1171 assert_eq!(
1172 doc.headings[0].body,
1173 "DEADLINE: is discussed in the design note."
1174 );
1175 }
1176
1177 #[test]
1178 fn a_legacy_tags_property_moves_to_the_name_org_leaves_alone() {
1179 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";
1182 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1183 let h = &doc.headings[0];
1184 assert_eq!(h.tags(), vec!["needs-review", "perf"]);
1185 let rendered = h.render();
1186 assert!(
1187 rendered.contains(":VISSUE_TAGS: needs-review"),
1188 "{rendered}"
1189 );
1190 assert!(rendered.contains(":perf:"), "{rendered}");
1191 assert!(!rendered.contains(":TAGS:"), "{rendered}");
1192 }
1193
1194 #[test]
1195 fn bodies_end_at_the_next_heading() {
1196 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";
1197 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1198 assert_eq!(doc.headings.len(), 2);
1199 assert_eq!(doc.headings[0].body, "First body.");
1200 assert_eq!(doc.headings[1].body, "Second body.\nMulti.");
1201 }
1202
1203 #[test]
1204 fn logbook_survives_a_document_round_trip() {
1205 let mut h = sample_heading();
1206 h.logbook = vec![LogEntry {
1207 timestamp: "[2026-04-26 Sun 09:15]".into(),
1208 from_state: Some("TODO".into()),
1209 to_state: Some("STARTED".into()),
1210 note: None,
1211 raw: None,
1212 }];
1213 let mut content = String::from("#+TITLE: sample issues\n\n");
1214 content.push_str(&h.render());
1215 let parsed = IssueDoc::parse("sample", PathBuf::from("/tmp/x.org"), &content).unwrap();
1216 assert_eq!(parsed.headings[0].logbook.len(), 1);
1217 assert_eq!(
1218 parsed.headings[0].logbook[0].from_state.as_deref(),
1219 Some("TODO")
1220 );
1221 }
1222
1223 #[test]
1224 fn write_then_reread_finds_the_heading() {
1225 let dir = tempfile::tempdir().unwrap();
1226 let path = dir.path().join("Software/sample/issues.org");
1227 IssueDoc {
1228 project: "sample".into(),
1229 path: path.clone(),
1230 preamble: default_preamble("sample"),
1231 tag_settings: tag_settings_from_preamble(&default_preamble("sample")),
1232 headings: vec![sample_heading()],
1233 after: vec![String::new()],
1234 }
1235 .write()
1236 .unwrap();
1237 let parsed = IssueDoc::parse_file("sample", &path).unwrap();
1238 assert_eq!(parsed.headings[0].id, "sample-abc1");
1239 }
1240
1241 #[test]
1242 fn write_preserves_the_existing_preamble_and_property_order() {
1243 let dir = tempfile::tempdir().unwrap();
1244 let path = dir.path().join("Software/sample/issues.org");
1245 fs::create_dir_all(path.parent().unwrap()).unwrap();
1246 fs::write(
1247 &path,
1248 "#+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",
1249 )
1250 .unwrap();
1251
1252 let mut doc = IssueDoc::parse_file("sample", &path).unwrap();
1253 doc.headings[0].priority = 'B';
1254 doc.write().unwrap();
1255 let written = fs::read_to_string(&path).unwrap();
1256
1257 assert!(written.contains("#+FILETAGS: :issues:sample:"), "{written}");
1258 assert!(written.contains("#+CATEGORY: sample"), "{written}");
1259 assert!(written.contains("#+STATUS: Active"), "{written}");
1260 assert!(
1261 written.find(":TYPE:").unwrap() < written.find(":PARENT:").unwrap(),
1262 "{written}"
1263 );
1264 }
1265
1266 #[test]
1267 fn a_gcal_event_heading_is_not_an_issue() {
1268 let dir = tempfile::tempdir().unwrap();
1269 let path = dir.path().join("Software/sample/issues.org");
1270 fs::create_dir_all(path.parent().unwrap()).unwrap();
1271 fs::write(
1272 &path,
1273 "#+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",
1274 )
1275 .unwrap();
1276 let doc = IssueDoc::parse_file("sample", &path).unwrap();
1277 assert_eq!(
1278 doc.headings.len(),
1279 1,
1280 "{:?}",
1281 doc.headings.iter().map(|h| &h.id).collect::<Vec<_>>()
1282 );
1283 assert_eq!(doc.headings[0].id, "sample-aaaa");
1284 let written = doc.render_string();
1285 assert!(
1286 written.contains("abc123/primary@group.calendar.google.com"),
1287 "{written}"
1288 );
1289 }
1290
1291 #[test]
1292 fn setupfile_todo_keywords_make_an_issue() {
1293 let dir = tempfile::tempdir().unwrap();
1294 let setup = dir.path().join("house.org");
1295 fs::write(&setup, "#+TODO: TODO HOLD | DONE\n").unwrap();
1296 let path = dir.path().join("Software/sample/issues.org");
1297 fs::create_dir_all(path.parent().unwrap()).unwrap();
1298 fs::write(
1299 &path,
1300 format!(
1301 "#+TITLE: sample issues\n#+SETUPFILE: {}\n\n* HOLD [#C] Parked\n:PROPERTIES:\n:ID: sample-hold\n:END:\n",
1302 setup.display()
1303 ),
1304 )
1305 .unwrap();
1306 let doc = IssueDoc::parse_file("sample", &path).unwrap();
1307 assert_eq!(doc.headings.len(), 1);
1308 assert_eq!(doc.headings[0].state, "HOLD");
1309 assert_eq!(doc.priority_spec().default, 'C');
1310 }
1311
1312 #[test]
1313 fn file_priorities_set_the_missing_cookie() {
1314 let dir = tempfile::tempdir().unwrap();
1315 let path = dir.path().join("Software/sample/issues.org");
1316 fs::create_dir_all(path.parent().unwrap()).unwrap();
1317 fs::write(
1318 &path,
1319 "#+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",
1320 )
1321 .unwrap();
1322 let doc = IssueDoc::parse_file("sample", &path).unwrap();
1323 assert_eq!(doc.headings[0].priority, 'B');
1324 assert_eq!(doc.priority_spec().lowest, 'D');
1325 }
1326
1327 #[test]
1328 fn an_empty_document_writes_the_house_preamble() {
1329 let dir = tempfile::tempdir().unwrap();
1330 let path = dir.path().join("Software/sample/issues.org");
1331 IssueDoc::empty("sample", path.clone()).write().unwrap();
1332 let written = fs::read_to_string(&path).unwrap();
1333 for expected in [
1334 "#+TITLE: sample issues",
1335 "#+VISSUE: 1",
1336 "#+CATEGORY: sample",
1339 "#+FILETAGS: :issues:sample:noexport:",
1340 "#+TAGS: { bug(b) feature(f) task(t) chore(c) plan(p) }",
1341 "#+PRIORITIES: A C C",
1342 "#+EXCLUDE_TAGS: noexport",
1343 "#+SELECT_TAGS: export",
1344 "#+DATE:",
1345 "#+STATUS: Active",
1346 TODO_HEADER,
1347 ] {
1348 assert!(written.contains(expected), "missing {expected}: {written}");
1349 }
1350 }
1351
1352 #[test]
1353 fn generated_ids_are_unique_and_sized() {
1354 let existing = vec!["p-aaaa".to_string()];
1355 let id = generate_id("p", &existing, 4).unwrap();
1356 assert!(id.starts_with("p-"));
1357 assert!(!existing.contains(&id));
1358 assert_eq!(id.len(), 1 + 1 + 4);
1359 assert_eq!(generate_id("q", &[], 6).unwrap().len(), 1 + 1 + 6);
1360 }
1361
1362 #[test]
1363 fn a_full_suffix_space_is_an_error_and_not_a_panic() {
1364 let mut existing = Vec::new();
1367 for a in ID_ALPHABET {
1368 for b in ID_ALPHABET {
1369 existing.push(format!("p-{}{}", *a as char, *b as char));
1370 }
1371 }
1372 let err = generate_id("p", &existing, 2).unwrap_err();
1373 assert!(err.to_string().contains("id_length"), "{err}");
1374 existing.pop();
1376 assert!(generate_id("p", &existing, 2).is_ok());
1377 }
1378
1379 #[test]
1380 fn projects_are_discovered_under_the_configured_prefix() {
1381 let dir = tempfile::tempdir().unwrap();
1382 let layout = Layout::new(dir.path(), "tracker");
1383 for project in ["beta", "alpha"] {
1384 IssueDoc::empty(project, layout.project_issues_path(project))
1385 .write()
1386 .unwrap();
1387 }
1388 assert_eq!(list_projects(&layout).unwrap(), vec!["alpha", "beta"]);
1389 assert!(
1390 list_projects(&Layout::new(dir.path(), DEFAULT_PREFIX))
1391 .unwrap()
1392 .is_empty()
1393 );
1394 }
1395
1396 #[test]
1397 fn project_case_resolves_to_the_directory_on_disk() {
1398 let dir = tempfile::tempdir().unwrap();
1399 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
1400 IssueDoc::empty("MixedCase", layout.project_issues_path("MixedCase"))
1401 .write()
1402 .unwrap();
1403 assert_eq!(
1404 resolve_existing_project_case(&layout, "mixedcase").unwrap(),
1405 "MixedCase"
1406 );
1407 assert_eq!(
1408 resolve_existing_project_case(&layout, "brand-new").unwrap(),
1409 "brand-new"
1410 );
1411 }
1412
1413 #[test]
1414 fn project_context_file_is_found_by_walking_up() {
1415 let dir = tempfile::tempdir().unwrap();
1416 let nested = dir.path().join("a/b/c");
1417 fs::create_dir_all(&nested).unwrap();
1418 fs::write(
1419 dir.path().join(".project-ctx.toml"),
1420 "[project]\nname = \"demoproj\"\n",
1421 )
1422 .unwrap();
1423 assert_eq!(
1424 detect_project_from_ctx(&nested).as_deref(),
1425 Some("demoproj")
1426 );
1427 let empty = tempfile::tempdir().unwrap();
1428 assert!(detect_project_from_ctx(empty.path()).is_none());
1429 }
1430
1431 fn ids(content: &str) -> Vec<&str> {
1432 org_ids(content).collect()
1433 }
1434
1435 #[test]
1436 fn a_drawer_under_a_headline_defines_an_id() {
1437 assert_eq!(
1438 ids("* TODO [#B] a title\n:PROPERTIES:\n:ID: atlas-1a2b\n:END:\n"),
1439 ["atlas-1a2b"]
1440 );
1441 }
1442
1443 #[test]
1444 fn a_drawer_under_the_planning_line_defines_an_id() {
1445 let text = concat!(
1446 "* TODO a title\n",
1447 "DEADLINE: <2026-05-15 Fri>\n",
1448 ":PROPERTIES:\n",
1449 ":ID: atlas-1a2b\n",
1450 ":END:\n",
1451 );
1452 assert_eq!(ids(text), ["atlas-1a2b"]);
1453 }
1454
1455 #[test]
1456 fn a_logbook_beside_the_properties_does_not_hide_it() {
1457 let text = concat!(
1458 "* TODO a title\n",
1459 ":LOGBOOK:\n",
1460 "- claimed by worker-1\n",
1461 ":END:\n",
1462 ":PROPERTIES:\n",
1463 ":ID: atlas-1a2b\n",
1464 ":END:\n",
1465 );
1466 assert_eq!(ids(text), ["atlas-1a2b"]);
1467 }
1468
1469 #[test]
1470 fn an_id_quoted_in_a_body_defines_nothing() {
1471 let text = concat!(
1473 "* TODO a title\n",
1474 ":PROPERTIES:\n",
1475 ":ID: atlas-1a2b\n",
1476 ":END:\n",
1477 "\n",
1478 "The heading I was handed reads:\n",
1479 ":PROPERTIES:\n",
1480 ":ID: ghost-9999\n",
1481 ":END:\n",
1482 );
1483 assert_eq!(
1484 ids(text),
1485 ["atlas-1a2b"],
1486 "a report that quotes an id defined it"
1487 );
1488 }
1489
1490 #[test]
1491 fn a_bare_id_line_in_a_body_defines_nothing() {
1492 let text = concat!(
1493 "* TODO a title\n",
1494 ":PROPERTIES:\n",
1495 ":ID: atlas-1a2b\n",
1496 ":END:\n",
1497 "\n",
1498 "Compare with :ID: ghost-9999 in the other file.\n",
1499 ":ID: ghost-8888\n",
1500 );
1501 assert_eq!(ids(text), ["atlas-1a2b"]);
1502 }
1503
1504 #[test]
1505 fn a_file_level_drawer_defines_an_id() {
1506 let text = concat!(
1508 "#+TITLE: atlas issues\n",
1509 "\n",
1510 ":PROPERTIES:\n",
1511 ":ID: the-file-itself\n",
1512 ":END:\n",
1513 "\n",
1514 "* TODO a title\n",
1515 ":PROPERTIES:\n",
1516 ":ID: atlas-1a2b\n",
1517 ":END:\n",
1518 );
1519 assert_eq!(ids(text), ["the-file-itself", "atlas-1a2b"]);
1520 }
1521
1522 #[test]
1523 fn every_headline_depth_opens_a_drawer_site() {
1524 let text = concat!(
1525 "* TODO a title\n",
1526 ":PROPERTIES:\n",
1527 ":ID: atlas-1a2b\n",
1528 ":END:\n",
1529 "** A sub-heading someone wrote by hand\n",
1530 ":PROPERTIES:\n",
1531 ":ID: atlas-3c4d\n",
1532 ":END:\n",
1533 );
1534 assert_eq!(ids(text), ["atlas-1a2b", "atlas-3c4d"]);
1535 }
1536
1537 #[test]
1538 fn a_planning_keyword_needs_its_colon() {
1539 assert!(is_planning_line("DEADLINE: <2026-05-15 Fri>"));
1540 assert!(is_planning_line("CLOSED: [2026-05-15 Fri]"));
1541 assert!(!is_planning_line("DEADLINES slipped again"));
1543 assert!(!is_planning_line("SCHEDULED work for the week"));
1544 }
1545
1546 #[test]
1547 fn prose_that_opens_like_a_planning_line_still_ends_the_drawer_site() {
1548 let text = concat!(
1552 "* TODO a title\n",
1553 ":PROPERTIES:\n",
1554 ":ID: atlas-1a2b\n",
1555 ":END:\n",
1556 "\n",
1557 "DEADLINE: is discussed in the design note.\n",
1558 ":PROPERTIES:\n",
1559 ":ID: ghost-9999\n",
1560 ":END:\n",
1561 );
1562 assert_eq!(ids(text), ["atlas-1a2b"]);
1563 }
1564
1565 #[test]
1566 fn a_headline_needs_a_space_after_its_stars() {
1567 assert!(is_headline("* TODO a title"));
1568 assert!(is_headline("*** deeper"));
1569 assert!(!is_headline("**bold** at the start of a line"));
1570 assert!(!is_headline("not a headline"));
1571 }
1572
1573 #[test]
1574 fn a_source_block_does_not_split_an_issue() {
1575 let content = concat!(
1576 "#+TITLE: x issues\n\n",
1577 "* TODO [#A] Real issue\n",
1578 ":PROPERTIES:\n",
1579 ":ID: x-aaaa\n",
1580 ":END:\n\n",
1581 "Quoted tracker:\n",
1582 "#+BEGIN_SRC org\n",
1583 "* TODO quoted\n",
1584 ":PROPERTIES:\n",
1585 ":ID: ghost-9999\n",
1586 ":END:\n",
1587 "#+END_SRC\n\n",
1588 "Still the same issue.\n",
1589 );
1590 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1591 assert_eq!(
1592 doc.headings.len(),
1593 1,
1594 "{:?}",
1595 doc.headings.iter().map(|h| &h.id).collect::<Vec<_>>()
1596 );
1597 assert_eq!(doc.headings[0].id, "x-aaaa");
1598 assert!(
1599 doc.headings[0].body.contains("* TODO quoted"),
1600 "{}",
1601 doc.headings[0].body
1602 );
1603 assert!(doc.headings[0].body.contains("Still the same issue."));
1604 }
1605
1606 #[test]
1607 fn org_ids_ignore_a_drawer_inside_a_block() {
1608 let text = concat!(
1609 "#+BEGIN_SRC org\n",
1610 "* TODO quoted\n",
1611 ":PROPERTIES:\n",
1612 ":ID: ghost-9999\n",
1613 ":END:\n",
1614 "#+END_SRC\n",
1615 "* TODO a title\n",
1616 ":PROPERTIES:\n",
1617 ":ID: atlas-1a2b\n",
1618 ":END:\n",
1619 );
1620 assert_eq!(ids(text), ["atlas-1a2b"]);
1621 }
1622
1623 #[test]
1624 fn a_timestamp_range_on_the_planning_line_parses() {
1625 let content = concat!(
1626 "#+TITLE: x issues\n\n",
1627 "* TODO [#A] Sprint\n",
1628 "SCHEDULED: <2026-09-01 Tue>--<2026-09-08 Tue> DEADLINE: <2026-09-15 Mon +1w>\n",
1629 ":PROPERTIES:\n",
1630 ":ID: x-aaaa\n",
1631 ":END:\n",
1632 );
1633 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1634 assert_eq!(doc.headings[0].id, "x-aaaa");
1635 assert_eq!(
1636 doc.headings[0].scheduled(),
1637 Some("<2026-09-01 Tue>--<2026-09-08 Tue>")
1638 );
1639 assert_eq!(doc.headings[0].deadline(), Some("<2026-09-15 Mon +1w>"));
1640 }
1641
1642 #[test]
1643 fn a_repeater_and_warning_on_the_planning_line_parse() {
1644 let content = concat!(
1645 "#+TITLE: x issues\n\n",
1646 "* TODO [#A] Weekly\n",
1647 "DEADLINE: <2026-09-01 Tue +1w -2d>\n",
1648 ":PROPERTIES:\n",
1649 ":ID: x-aaaa\n",
1650 ":END:\n",
1651 );
1652 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1653 assert_eq!(doc.headings[0].deadline(), Some("<2026-09-01 Tue +1w -2d>"));
1654 let rendered = doc.headings[0].render();
1655 assert!(
1656 rendered.contains("DEADLINE: <2026-09-01 Tue +1w -2d>"),
1657 "{rendered}"
1658 );
1659 }
1660
1661 #[test]
1662 fn a_logbook_before_properties_still_parses() {
1663 let content = concat!(
1664 "#+TITLE: x issues\n\n",
1665 "* TODO [#A] Clocked\n",
1666 ":LOGBOOK:\n",
1667 "CLOCK: [2026-08-18 Tue 10:00]--[2026-08-18 Tue 11:00] => 1:00\n",
1668 ":END:\n",
1669 ":PROPERTIES:\n",
1670 ":ID: x-aaaa\n",
1671 ":END:\n",
1672 );
1673 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1674 assert_eq!(doc.headings[0].id, "x-aaaa");
1675 assert_eq!(doc.headings[0].logbook.len(), 1);
1676 assert!(doc.headings[0].logbook[0].raw.is_some());
1677 }
1678
1679 #[test]
1680 fn other_drawers_at_the_drawer_site_do_not_hide_the_id() {
1681 let content = concat!(
1682 "#+TITLE: x issues\n\n",
1683 "* TODO [#A] Notes drawer\n",
1684 ":NOTES:\n",
1685 "hand written\n",
1686 ":END:\n",
1687 ":PROPERTIES:\n",
1688 ":ID: x-aaaa\n",
1689 ":END:\n\n",
1690 "Body stays.\n",
1691 );
1692 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1693 assert_eq!(doc.headings[0].id, "x-aaaa");
1694 assert_eq!(doc.headings[0].body, "Body stays.");
1695 let rendered = doc.headings[0].render();
1696 assert!(rendered.contains(":NOTES:"), "{rendered}");
1697 assert!(rendered.contains("hand written"), "{rendered}");
1698 }
1699
1700 #[test]
1701 fn a_comment_heading_is_not_an_issue() {
1702 let content = concat!(
1703 "#+TITLE: x issues\n\n",
1704 "* COMMENT Archived discussion\n",
1705 ":PROPERTIES:\n",
1706 ":ID: ghost-old\n",
1707 ":END:\n\n",
1708 "* TODO [#A] Live\n",
1709 ":PROPERTIES:\n",
1710 ":ID: x-aaaa\n",
1711 ":END:\n",
1712 );
1713 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1714 assert_eq!(doc.headings.len(), 1);
1715 assert_eq!(doc.headings[0].id, "x-aaaa");
1716 assert!(doc.preamble.contains("COMMENT Archived"));
1717 }
1718
1719 #[test]
1720 fn a_section_heading_round_trips() {
1721 let content = concat!(
1722 "#+TITLE: x issues\n\n",
1723 "* TODO [#A] First\n",
1724 ":PROPERTIES:\n",
1725 ":ID: x-aaaa\n",
1726 ":END:\n\n",
1727 "First body.\n\n",
1728 "* Notes\n",
1729 "Hand-written section.\n\n",
1730 "* TODO [#B] Second\n",
1731 ":PROPERTIES:\n",
1732 ":ID: x-bbbb\n",
1733 ":END:\n",
1734 );
1735 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1736 assert_eq!(doc.headings.len(), 2);
1737 assert_eq!(doc.headings[0].body, "First body.");
1738 assert!(doc.after[0].contains("* Notes"));
1739 assert!(doc.after[0].contains("Hand-written section."));
1740 let rendered = doc.headings[0].render();
1741 let mut file = String::from("#+TITLE: x issues\n\n");
1742 file.push_str(&rendered);
1743 file.push('\n');
1744 file.push_str(&doc.after[0]);
1745 file.push_str(&doc.headings[1].render());
1746 let again = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), &file).unwrap();
1747 assert_eq!(again.headings.len(), 2);
1748 assert!(again.after[0].contains("* Notes"));
1749 }
1750
1751 #[test]
1752 fn a_file_local_todo_keyword_is_an_issue() {
1753 let content = concat!(
1754 "#+TITLE: x issues\n",
1755 "#+TODO: TODO WAIT | DONE\n\n",
1756 "* WAIT [#B] Parked\n",
1757 ":PROPERTIES:\n",
1758 ":ID: x-aaaa\n",
1759 ":END:\n",
1760 );
1761 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1762 assert_eq!(doc.headings.len(), 1);
1763 assert_eq!(doc.headings[0].state, "WAIT");
1764 assert_eq!(doc.headings[0].id, "x-aaaa");
1765 }
1766
1767 #[test]
1768 fn a_statistics_cookie_is_not_the_title() {
1769 let content = concat!(
1770 "#+TITLE: x issues\n\n",
1771 "* TODO [#A] Break it down [2/5] :plan:\n",
1772 ":PROPERTIES:\n",
1773 ":ID: x-aaaa\n",
1774 ":END:\n",
1775 );
1776 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1777 assert_eq!(doc.headings[0].title, "Break it down");
1778 assert_eq!(doc.headings[0].statistics.as_deref(), Some("[2/5]"));
1779 assert_eq!(doc.headings[0].org_tags, vec!["plan"]);
1780 let line = doc.headings[0].render().lines().next().unwrap().to_string();
1781 assert!(line.contains("[2/5]"), "{line:?}");
1782 assert!(line.contains(":plan:"), "{line:?}");
1783 }
1784
1785 #[test]
1786 fn a_property_plus_appends() {
1787 let content = concat!(
1788 "#+TITLE: x issues\n\n",
1789 "* TODO [#A] Blocked\n",
1790 ":PROPERTIES:\n",
1791 ":ID: x-aaaa\n",
1792 ":BLOCKED_BY: x-bbbb\n",
1793 ":BLOCKED_BY+: x-cccc\n",
1794 ":END:\n",
1795 );
1796 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1797 assert_eq!(doc.headings[0].blocked_by(), vec!["x-bbbb", "x-cccc"]);
1798 }
1799
1800 #[test]
1801 fn babel_results_do_not_split_an_issue_or_define_an_id() {
1802 let content = concat!(
1803 "#+TITLE: x issues\n\n",
1804 "* TODO [#A] Real issue\n",
1805 ":PROPERTIES:\n",
1806 ":ID: x-aaaa\n",
1807 ":END:\n\n",
1808 "#+NAME: dump\n",
1809 "#+HEADER: :results raw\n",
1810 "#+BEGIN_SRC python :results raw\n",
1811 "print('* TODO dumped')\n",
1812 "#+END_SRC\n\n",
1813 "#+RESULTS:\n",
1814 "* TODO dumped\n",
1815 ":PROPERTIES:\n",
1816 ":ID: ghost-9999\n",
1817 ":END:\n\n",
1818 "Still the same issue.\n\n",
1819 "* TODO [#B] Next\n",
1820 ":PROPERTIES:\n",
1821 ":ID: x-bbbb\n",
1822 ":END:\n",
1823 );
1824 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1825 assert_eq!(
1826 doc.headings
1827 .iter()
1828 .map(|h| h.id.as_str())
1829 .collect::<Vec<_>>(),
1830 ["x-aaaa", "x-bbbb"]
1831 );
1832 assert!(
1833 doc.headings[0].body.contains("#+RESULTS:"),
1834 "{}",
1835 doc.headings[0].body
1836 );
1837 assert!(
1838 doc.headings[0].body.contains("* TODO dumped"),
1839 "{}",
1840 doc.headings[0].body
1841 );
1842 assert!(doc.headings[0].body.contains("Still the same issue."));
1843 assert_eq!(ids(content), ["x-aaaa", "x-bbbb"]);
1844 }
1845
1846 #[test]
1847 fn a_babel_call_with_results_drawer_stays_in_the_body() {
1848 let content = concat!(
1849 "#+TITLE: x issues\n\n",
1850 "* TODO [#A] Calls a named block\n",
1851 ":PROPERTIES:\n",
1852 ":ID: x-aaaa\n",
1853 ":END:\n\n",
1854 "#+CALL: plot(x=1) :results drawer\n",
1855 "#+RESULTS:\n",
1856 ":RESULTS:\n",
1857 "* TODO not an issue\n",
1858 ":END:\n",
1859 );
1860 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1861 assert_eq!(doc.headings.len(), 1);
1862 assert!(doc.headings[0].body.contains("#+CALL: plot"));
1863 assert!(doc.headings[0].body.contains("* TODO not an issue"));
1864 assert_eq!(ids(content), ["x-aaaa"]);
1865 }
1866}