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