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