1use anyhow::{anyhow, Context, Result};
4use fs2::FileExt;
5use std::collections::{BTreeMap, HashMap};
6use std::fs;
7use std::path::{Path, PathBuf};
8use std::sync::atomic::{AtomicU64, Ordering};
9use std::sync::{Arc, Mutex, OnceLock};
10use std::time::{SystemTime, UNIX_EPOCH};
11
12use crate::config::Layout;
13use crate::model::TODO_KEYWORDS;
14use crate::model::{parse_log_line, today_inactive_bracket, IssueHeading, LogEntry, TODO_HEADER};
15
16static PROCESS_LOCKS: OnceLock<Mutex<HashMap<PathBuf, Arc<Mutex<()>>>>> = OnceLock::new();
20static WRITE_TMP_SEQ: AtomicU64 = AtomicU64::new(0);
21
22const ID_ALPHABET: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz";
23
24struct CrossProcessLock {
25 file: fs::File,
26}
27
28impl CrossProcessLock {
29 fn acquire(path: &Path) -> Result<Self> {
30 let lock_path = issues_lock_path(path);
31 if let Some(parent) = lock_path.parent() {
32 fs::create_dir_all(parent)
33 .with_context(|| format!("create lock parent {}", parent.display()))?;
34 }
35 let file = fs::OpenOptions::new()
36 .create(true)
37 .read(true)
38 .append(true)
39 .open(&lock_path)
40 .with_context(|| format!("open lock {}", lock_path.display()))?;
41 file.lock_exclusive()
42 .with_context(|| format!("lock {}", lock_path.display()))?;
43 Ok(Self { file })
44 }
45}
46
47impl Drop for CrossProcessLock {
48 fn drop(&mut self) {
49 let _ = fs2::FileExt::unlock(&self.file);
50 }
51}
52
53fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
56 use std::io::Write as _;
57 let mut file = fs::File::create(path)?;
58 file.write_all(bytes)?;
59 file.sync_all()
60}
61
62pub fn replace_file_atomically(path: &Path, body: &str) -> Result<()> {
68 let parent = path
69 .parent()
70 .filter(|p| !p.as_os_str().is_empty())
71 .map(Path::to_path_buf)
72 .unwrap_or_else(|| PathBuf::from("."));
73 fs::create_dir_all(&parent).with_context(|| format!("create {}", parent.display()))?;
74 let seq = WRITE_TMP_SEQ.fetch_add(1, Ordering::Relaxed);
75 let base = path
76 .file_name()
77 .and_then(|s| s.to_str())
78 .unwrap_or("output");
79 let tmp = parent.join(format!(".{}.tmp.{}-{}", base, std::process::id(), seq));
80 if let Err(e) = write_synced(&tmp, body.as_bytes()) {
81 let _ = fs::remove_file(&tmp);
82 return Err(e).with_context(|| format!("write temp {}", tmp.display()));
83 }
84 if let Err(e) = fs::rename(&tmp, path) {
85 let _ = fs::remove_file(&tmp);
86 return Err(e).with_context(|| format!("rename {} -> {}", tmp.display(), path.display()));
87 }
88 Ok(())
89}
90
91fn issues_lock_path(path: &Path) -> PathBuf {
92 let mut s = path.as_os_str().to_owned();
93 s.push(".lock");
94 PathBuf::from(s)
95}
96
97fn process_mutex_for(path: &Path) -> Arc<Mutex<()>> {
98 let key = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
99 let mut map = PROCESS_LOCKS
100 .get_or_init(|| Mutex::new(HashMap::new()))
101 .lock()
102 .unwrap_or_else(|p| p.into_inner());
103 map.entry(key)
104 .or_insert_with(|| Arc::new(Mutex::new(())))
105 .clone()
106}
107
108pub fn with_issues_lock<R, F>(path: &Path, f: F) -> Result<R>
113where
114 F: FnOnce() -> Result<R>,
115{
116 let mutex = process_mutex_for(path);
117 let _proc = mutex.lock().unwrap_or_else(|p| p.into_inner());
118 let _cross = CrossProcessLock::acquire(path)?;
119 f()
120}
121
122pub fn with_issues_locks<R, F>(paths: &[&Path], f: F) -> Result<R>
125where
126 F: FnOnce() -> Result<R>,
127{
128 let mut keys: Vec<PathBuf> = paths.iter().map(|p| (*p).to_path_buf()).collect();
129 keys.sort();
130 keys.dedup();
131 let mutexes: Vec<Arc<Mutex<()>>> = keys.iter().map(|k| process_mutex_for(k)).collect();
132 let mut proc_guards = Vec::with_capacity(mutexes.len());
133 let mut cross_guards = Vec::with_capacity(keys.len());
134 for (key, mutex) in keys.iter().zip(mutexes.iter()) {
135 proc_guards.push(mutex.lock().unwrap_or_else(|p| p.into_inner()));
136 cross_guards.push(CrossProcessLock::acquire(key)?);
137 }
138 f()
139}
140
141#[derive(Debug, Clone)]
143pub struct IssueDoc {
144 pub project: String,
145 pub path: PathBuf,
146 pub preamble: String,
147 pub headings: Vec<IssueHeading>,
148}
149
150impl IssueDoc {
151 pub fn empty(project: &str, path: PathBuf) -> Self {
152 IssueDoc {
153 project: project.to_string(),
154 path,
155 preamble: default_preamble(project),
156 headings: Vec::new(),
157 }
158 }
159
160 pub fn parse_file(project: &str, path: &Path) -> Result<Self> {
162 if !path.exists() {
163 return Ok(Self::empty(project, path.to_path_buf()));
164 }
165 let content =
166 fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
167 Self::parse(project, path.to_path_buf(), &content)
168 }
169
170 pub fn parse(project: &str, path: PathBuf, content: &str) -> Result<Self> {
171 let lines: Vec<&str> = content.lines().collect();
172 let first_heading = lines
173 .iter()
174 .position(|line| line.starts_with("* "))
175 .unwrap_or(lines.len());
176 let preamble = if first_heading == 0 {
177 default_preamble(project)
178 } else {
179 lines[..first_heading].join("\n").trim_end().to_string()
180 };
181 let mut headings = Vec::new();
182 let mut i = first_heading;
183 while i < lines.len() {
184 if lines[i].starts_with("* ") {
185 let (heading, end_idx) = parse_heading(&lines, i)
186 .with_context(|| format!("at {}:{}", path.display(), i + 1))?;
187 headings.push(heading);
188 i = end_idx;
189 } else {
190 i += 1;
191 }
192 }
193 Ok(IssueDoc {
194 project: project.to_string(),
195 path,
196 preamble,
197 headings,
198 })
199 }
200
201 pub fn write(&self) -> Result<()> {
204 if let Some(parent) = self.path.parent() {
205 fs::create_dir_all(parent)?;
206 }
207 let mut out = String::new();
208 let preamble = if self.preamble.trim().is_empty() {
209 default_preamble(&self.project)
210 } else {
211 self.preamble.clone()
212 };
213 out.push_str(preamble.trim_end());
214 out.push_str("\n\n");
215 for h in &self.headings {
216 out.push_str(&h.render());
217 out.push('\n');
218 }
219 let seq = WRITE_TMP_SEQ.fetch_add(1, Ordering::Relaxed);
222 let nanos = SystemTime::now()
223 .duration_since(UNIX_EPOCH)
224 .map(|d| d.as_nanos())
225 .unwrap_or(0);
226 let base = self
227 .path
228 .file_name()
229 .and_then(|s| s.to_str())
230 .unwrap_or("issues.org");
231 let tmp = self
232 .path
233 .parent()
234 .unwrap_or_else(|| Path::new("."))
235 .join(format!(
236 ".{}.tmp.{}-{}-{}",
237 base,
238 std::process::id(),
239 nanos,
240 seq
241 ));
242 if let Err(e) = write_synced(&tmp, out.as_bytes()) {
246 let _ = fs::remove_file(&tmp);
247 return Err(e).with_context(|| format!("write temp {}", tmp.display()));
248 }
249 if let Err(e) = fs::rename(&tmp, &self.path) {
250 let _ = fs::remove_file(&tmp);
251 return Err(e)
252 .with_context(|| format!("rename {} -> {}", tmp.display(), self.path.display()));
253 }
254 self.announce_write();
255 Ok(())
256 }
257
258 fn announce_write(&self) {
262 if !crate::events::enabled() {
263 return;
264 }
265 let Some(dir) = self.path.parent().and_then(|p| p.parent()) else {
266 return;
267 };
268 let _ = crate::events::emit_issues_write(dir, &self.project, &self.path);
269 let _ = crate::events::ensure_gitignore_hint(dir);
270 }
271
272 pub fn known_ids(&self) -> Vec<String> {
273 self.headings.iter().map(|h| h.id.clone()).collect()
274 }
275
276 pub fn upsert(&mut self, heading: IssueHeading) {
277 if let Some(slot) = self.headings.iter_mut().find(|h| h.id == heading.id) {
278 *slot = heading;
279 } else {
280 self.headings.push(heading);
281 }
282 }
283
284 pub fn remove(&mut self, id: &str) -> Option<IssueHeading> {
285 let idx = self.headings.iter().position(|h| h.id == id)?;
286 Some(self.headings.remove(idx))
287 }
288}
289
290fn parse_heading(lines: &[&str], start: usize) -> Result<(IssueHeading, usize)> {
291 let header = lines[start];
292 let stripped = header
293 .strip_prefix("* ")
294 .ok_or_else(|| anyhow!("not a heading"))?;
295
296 let trimmed = stripped.trim();
297 let (state, after) = trimmed
298 .split_once(' ')
299 .map(|(s, a)| (s.to_string(), a.trim_start()))
300 .unwrap_or((trimmed.to_string(), ""));
301 if !TODO_KEYWORDS.contains(&state.as_str()) {
302 return Err(anyhow!("unknown TODO keyword {:?}", state));
303 }
304
305 let (priority, heading_text) = match parse_priority_cookie(after) {
306 Some((p, rest)) => (p, rest.trim()),
307 None => ('C', after),
308 };
309 let (title, org_tags) = crate::model::split_headline_tags(heading_text);
310
311 let mut properties = BTreeMap::new();
312 let mut property_order = Vec::new();
313 let mut logbook: Vec<LogEntry> = Vec::new();
314 let mut i = start + 1;
315
316 while i < lines.len() {
320 let found = parse_planning_line(lines[i]);
321 if found.is_empty() {
322 break;
323 }
324 for (key, value) in found {
325 if !property_order.contains(&key) {
326 property_order.push(key.clone());
327 }
328 properties.insert(key, value);
329 }
330 i += 1;
331 }
332
333 let mut body_start = i;
334 if i < lines.len() && lines[i].trim() == ":PROPERTIES:" {
335 i += 1;
336 while i < lines.len() && lines[i].trim() != ":END:" {
337 let line = lines[i].trim();
338 if let Some(rest) = line.strip_prefix(':') {
339 if let Some(idx) = rest.find(':') {
340 let key = rest[..idx].to_string();
341 let val = rest[idx + 1..].trim().to_string();
342 if !property_order.contains(&key) {
343 property_order.push(key.clone());
344 }
345 properties.insert(key, val);
346 }
347 }
348 i += 1;
349 }
350 if i < lines.len() {
351 i += 1;
352 }
353 body_start = i;
354 }
355
356 if i < lines.len() && lines[i].trim() == ":LOGBOOK:" {
357 i += 1;
358 while i < lines.len() && lines[i].trim() != ":END:" {
359 if !lines[i].trim().is_empty() {
360 logbook.push(parse_log_line(lines[i]));
361 }
362 i += 1;
363 }
364 if i < lines.len() {
365 i += 1;
366 }
367 body_start = i;
368 }
369
370 let mut body_end = body_start;
371 while body_end < lines.len() && !lines[body_end].starts_with("* ") {
372 body_end += 1;
373 }
374 let body = lines[body_start..body_end]
375 .join("\n")
376 .trim_matches('\n')
377 .trim_end()
378 .to_string();
379
380 if let Some(legacy) = properties.remove(crate::model::LEGACY_TAGS_PROPERTY) {
384 property_order.retain(|key| key != crate::model::LEGACY_TAGS_PROPERTY);
385 properties
386 .entry(crate::model::TAGS_PROPERTY.to_string())
387 .or_insert(legacy);
388 }
389
390 let id = properties
391 .get("ID")
392 .cloned()
393 .ok_or_else(|| anyhow!(":ID: property missing"))?;
394
395 Ok((
396 IssueHeading {
397 id,
398 title,
399 state,
400 priority,
401 properties,
402 org_tags,
403 property_order,
404 body,
405 logbook,
406 line_start: start + 1,
407 line_end: if body_end == 0 { 1 } else { body_end },
408 },
409 body_end,
410 ))
411}
412
413fn parse_planning_line(line: &str) -> Vec<(String, String)> {
418 let mut rest = line.trim();
419 let mut found = Vec::new();
420 while !rest.is_empty() {
421 let Some(key) = crate::model::PLANNING_KEYS
422 .iter()
423 .find(|key| rest.starts_with(&format!("{key}:")))
424 else {
425 return Vec::new();
426 };
427 let after = rest[key.len() + 1..].trim_start();
428 let close = match after.chars().next() {
429 Some('<') => '>',
430 Some('[') => ']',
431 _ => return Vec::new(),
432 };
433 let Some(end) = after.find(close) else {
434 return Vec::new();
435 };
436 found.push((key.to_string(), after[..=end].to_string()));
437 rest = after[end + 1..].trim_start();
438 }
439 found
440}
441
442fn parse_priority_cookie(after: &str) -> Option<(char, &str)> {
449 let rest = after.strip_prefix("[#")?;
450 let mut chars = rest.char_indices();
451 let (_, priority) = chars.next()?;
452 let (close, bracket) = chars.next()?;
453 if bracket != ']' {
454 return None;
455 }
456 Some((priority, &rest[close + 1..]))
457}
458
459pub fn default_preamble(project: &str) -> String {
465 format!(
466 "#+TITLE: {project} issues\n#+CATEGORY: {project}\n#+FILETAGS: :issues:{project}:\n#+DATE: {}\n#+DESCRIPTION: Issue tracking file for {project} specs, plans, and implementation tasks.\n#+STATUS: Active\n{}",
467 today_inactive_bracket(),
468 TODO_HEADER
469 )
470}
471
472pub fn list_projects(layout: &Layout) -> Result<Vec<String>> {
474 let dir = layout.projects_dir();
475 if !dir.exists() {
476 return Ok(Vec::new());
477 }
478 let mut projects = Vec::new();
479 for entry in fs::read_dir(&dir).with_context(|| format!("read dir {}", dir.display()))? {
480 let entry = entry?;
481 let path = entry.path();
482 if path.is_dir() && path.join("issues.org").exists() {
483 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
484 projects.push(name.to_string());
485 }
486 }
487 }
488 projects.sort();
489 Ok(projects)
490}
491
492pub fn resolve_existing_project_case(layout: &Layout, project: &str) -> Result<String> {
495 if project.is_empty() {
496 return Ok(project.to_string());
497 }
498 if layout.project_issues_path(project).exists() {
499 return Ok(project.to_string());
500 }
501 let project_lower = project.to_lowercase();
502 let matches: Vec<String> = list_projects(layout)?
503 .into_iter()
504 .filter(|candidate| candidate.to_lowercase() == project_lower)
505 .collect();
506 match matches.as_slice() {
507 [] => Ok(project.to_string()),
508 [canonical] => Ok(canonical.clone()),
509 _ => Err(anyhow!(
510 "project {project:?} is ambiguous; case-insensitive matches: {}",
511 matches.join(", ")
512 )),
513 }
514}
515
516pub fn project_selected(project: &str, filter: Option<&str>) -> bool {
523 match filter {
524 None => true,
525 Some(p) => project.eq_ignore_ascii_case(p),
526 }
527}
528
529pub fn find_by_id(layout: &Layout, id: &str) -> Result<Option<(IssueHeading, PathBuf, String)>> {
531 for project in list_projects(layout)? {
532 let path = layout.project_issues_path(&project);
533 let doc = IssueDoc::parse_file(&project, &path)?;
534 for h in doc.headings {
535 if h.id == id {
536 return Ok(Some((h, path, project)));
537 }
538 }
539 }
540 Ok(None)
541}
542
543pub fn load_all(layout: &Layout) -> Result<Vec<(String, IssueHeading)>> {
545 let mut all = Vec::new();
546 for project in list_projects(layout)? {
547 let path = layout.project_issues_path(&project);
548 let doc = IssueDoc::parse_file(&project, &path)?;
549 for h in doc.headings {
550 all.push((project.clone(), h));
551 }
552 }
553 Ok(all)
554}
555
556pub fn generate_id(project: &str, existing: &[String], length: usize) -> Result<String> {
563 let len = length.max(2);
564 let taken: std::collections::HashSet<&str> = existing.iter().map(String::as_str).collect();
565 let base = SystemTime::now()
566 .duration_since(UNIX_EPOCH)
567 .map(|d| d.as_nanos())
568 .unwrap_or(1);
569 let attempts = 36usize
572 .checked_pow(len as u32)
573 .map(|space| space.saturating_mul(2))
574 .unwrap_or(usize::MAX)
575 .min(2_000_000);
576 for counter in 0..attempts as u128 {
577 let mut n = base
578 .wrapping_add(counter.wrapping_mul(17))
579 .wrapping_mul(2654435761);
580 let mut suffix = String::new();
581 for _ in 0..len {
582 suffix.push(ID_ALPHABET[(n as usize) % 36] as char);
583 n /= 36;
584 }
585 let id = format!("{}-{}", project, suffix);
586 if !taken.contains(id.as_str()) {
587 return Ok(id);
588 }
589 }
590 Err(anyhow!(
591 "no free id left for {project:?} at id_length = {len}; \
592 raise `id_length` under [issues] in vissue.toml"
593 ))
594}
595
596pub fn detect_project_from_ctx(start: &Path) -> Option<String> {
598 let mut dir = start.canonicalize().ok()?;
599 loop {
600 let candidate = dir.join(".project-ctx.toml");
601 if candidate.exists() {
602 if let Ok(text) = fs::read_to_string(&candidate) {
603 if let Ok(value) = text.parse::<toml::Value>() {
604 if let Some(name) = value
605 .get("project")
606 .and_then(|p| p.get("name"))
607 .and_then(|n| n.as_str())
608 {
609 return Some(name.to_string());
610 }
611 }
612 }
613 }
614 if !dir.pop() {
615 break;
616 }
617 }
618 None
619}
620
621pub fn find_org_ids(
634 layout: &Layout,
635 wanted: &std::collections::HashSet<String>,
636) -> Result<std::collections::HashSet<String>> {
637 let mut found = std::collections::HashSet::new();
638 if wanted.is_empty() {
639 return Ok(found);
640 }
641 let dir = layout.projects_dir();
642 if !dir.exists() {
643 return Ok(found);
644 }
645 for entry in walkdir::WalkDir::new(&dir)
646 .into_iter()
647 .filter_entry(|e| !is_skipped_dir(e))
648 {
649 let entry = match entry {
650 Ok(e) => e,
651 Err(_) => continue,
652 };
653 if !entry.file_type().is_file()
654 || entry.path().extension().and_then(|s| s.to_str()) != Some("org")
655 {
656 continue;
657 }
658 let content = fs::read_to_string(entry.path())
659 .with_context(|| format!("read {}", entry.path().display()))?;
660 for id in org_ids(&content) {
661 if wanted.contains(id) {
662 found.insert(id.to_string());
663 if found.len() == wanted.len() {
664 return Ok(found);
665 }
666 }
667 }
668 }
669 Ok(found)
670}
671
672pub fn collect_org_ids(layout: &Layout) -> Result<std::collections::HashSet<String>> {
673 let mut ids = std::collections::HashSet::new();
674 let dir = layout.projects_dir();
675 if !dir.exists() {
676 return Ok(ids);
677 }
678 for entry in walkdir::WalkDir::new(&dir)
679 .into_iter()
680 .filter_entry(|e| !is_skipped_dir(e))
681 {
682 let entry = match entry {
683 Ok(e) => e,
684 Err(_) => continue,
685 };
686 if !entry.file_type().is_file()
687 || entry.path().extension().and_then(|s| s.to_str()) != Some("org")
688 {
689 continue;
690 }
691 let content = fs::read_to_string(entry.path())
692 .with_context(|| format!("read {}", entry.path().display()))?;
693 for id in org_ids(&content) {
694 ids.insert(id.to_string());
695 }
696 }
697 Ok(ids)
698}
699
700fn is_skipped_dir(entry: &walkdir::DirEntry) -> bool {
701 if !entry.file_type().is_dir() {
702 return false;
703 }
704 let name = entry.file_name().to_string_lossy();
705 matches!(
706 name.as_ref(),
707 "node_modules" | "target" | ".git" | ".cache" | "build"
708 )
709}
710
711fn org_id_property_value(line: &str) -> Option<&str> {
712 let value = line.trim_start().strip_prefix(":ID:")?.trim();
713 if value.is_empty() {
714 None
715 } else {
716 Some(value)
717 }
718}
719
720fn org_ids(content: &str) -> impl Iterator<Item = &str> {
732 let mut at_drawer_site = true;
734 let mut in_drawer = false;
735 let mut drawer_is_properties = false;
736 let mut under_headline = false;
739
740 content.lines().filter_map(move |line| {
741 let trimmed = line.trim();
742
743 if in_drawer {
744 if trimmed.eq_ignore_ascii_case(":END:") {
745 in_drawer = false;
746 drawer_is_properties = false;
747 at_drawer_site = true;
750 return None;
751 }
752 if drawer_is_properties {
753 return org_id_property_value(line);
754 }
755 return None;
756 }
757
758 if is_headline(line) {
759 at_drawer_site = true;
760 under_headline = true;
761 return None;
762 }
763 let planning_may_start_here = under_headline;
764 under_headline = false;
765
766 if trimmed.is_empty() || trimmed.starts_with("#+") {
769 return None;
770 }
771 if at_drawer_site {
772 if opens_a_drawer(trimmed) {
773 in_drawer = true;
774 drawer_is_properties = trimmed.eq_ignore_ascii_case(":PROPERTIES:");
775 return None;
776 }
777 if planning_may_start_here && is_planning_line(trimmed) {
778 return None;
779 }
780 }
781 at_drawer_site = false;
783 None
784 })
785}
786
787fn is_headline(line: &str) -> bool {
789 let stars = line.len() - line.trim_start_matches('*').len();
790 stars > 0 && line[stars..].starts_with(' ')
791}
792
793fn opens_a_drawer(trimmed: &str) -> bool {
795 trimmed.len() > 2
796 && trimmed.starts_with(':')
797 && trimmed.ends_with(':')
798 && !trimmed.eq_ignore_ascii_case(":END:")
799 && !trimmed[1..trimmed.len() - 1].contains(char::is_whitespace)
800}
801
802fn is_planning_line(trimmed: &str) -> bool {
803 crate::model::PLANNING_KEYS
804 .iter()
805 .any(|key| trimmed.starts_with(key) && trimmed[key.len()..].starts_with(':'))
806}
807
808#[cfg(test)]
809mod tests {
810 use super::*;
811 use crate::config::DEFAULT_PREFIX;
812
813 fn sample_heading() -> IssueHeading {
814 let mut props = BTreeMap::new();
815 props.insert("ID".into(), "sample-abc1".into());
816 props.insert("CREATED".into(), "[2026-04-25 Sat]".into());
817 props.insert("TYPE".into(), "feature".into());
818 IssueHeading {
819 id: "sample-abc1".into(),
820 title: "Add a thing".into(),
821 state: "TODO".into(),
822 priority: 'A',
823 properties: props,
824 org_tags: Vec::new(),
825 property_order: vec!["ID".into(), "CREATED".into(), "TYPE".into()],
826 body: "Some body lines.\nWith multiple lines.".into(),
827 logbook: Vec::new(),
828 line_start: 4,
829 line_end: 12,
830 }
831 }
832
833 #[test]
834 fn render_then_parse_preserves_the_heading() {
835 let mut content = String::from("#+TITLE: sample issues\n");
836 content.push_str(TODO_HEADER);
837 content.push_str("\n\n");
838 content.push_str(&sample_heading().render());
839 let parsed = IssueDoc::parse("sample", PathBuf::from("/tmp/x.org"), &content).unwrap();
840 let h = &parsed.headings[0];
841 let original = sample_heading();
842 assert_eq!(h.id, original.id);
843 assert_eq!(h.title, original.title);
844 assert_eq!(h.state, original.state);
845 assert_eq!(h.priority, original.priority);
846 assert_eq!(h.body, original.body);
847 assert_eq!(h.properties.get("TYPE"), original.properties.get("TYPE"));
848 }
849
850 #[test]
851 fn heading_without_a_priority_cookie_defaults_to_c() {
852 let content =
853 "#+TITLE: x issues\n\n* TODO Just a title\n:PROPERTIES:\n:ID: x-aaaa\n:END:\n";
854 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
855 assert_eq!(doc.headings[0].priority, 'C');
856 assert_eq!(doc.headings[0].title, "Just a title");
857 }
858
859 #[test]
860 fn a_multibyte_priority_cookie_parses_instead_of_panicking() {
861 let content = "#+TITLE: x issues\n\n* TODO [#\u{2192}] Hand edited\n:PROPERTIES:\n:ID: x-aaaa\n:END:\n";
862 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
863 assert_eq!(doc.headings[0].priority, '\u{2192}');
864 assert_eq!(doc.headings[0].title, "Hand edited");
865 }
866
867 #[test]
868 fn a_title_opening_with_a_bracket_keeps_its_text() {
869 let content =
870 "#+TITLE: x issues\n\n* TODO [#not a cookie] stays\n:PROPERTIES:\n:ID: x-bbbb\n:END:\n";
871 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
872 assert_eq!(doc.headings[0].priority, 'C');
873 assert_eq!(doc.headings[0].title, "[#not a cookie] stays");
874 }
875
876 #[test]
877 fn an_org_planning_line_parses_instead_of_hiding_the_drawer() {
878 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";
882 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
883 let h = &doc.headings[0];
884 assert_eq!(h.id, "x-aaaa");
885 assert_eq!(h.deadline(), Some("<2026-09-01 Tue>"));
886 assert_eq!(h.scheduled(), Some("<2026-09-05 Sat>"));
887 assert_eq!(
888 h.properties.get("CLOSED").map(String::as_str),
889 Some("[2026-08-14 Fri 03:33]")
890 );
891 assert_eq!(h.body, "Body stays body.");
892 }
893
894 #[test]
895 fn a_planning_line_round_trips_in_orgs_own_order() {
896 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";
897 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
898 let rendered = doc.headings[0].render();
899 assert!(
900 rendered.contains(
901 "\nCLOSED: [2026-08-14 Fri 03:33] SCHEDULED: <2026-09-05 Sat> DEADLINE: <2026-09-01 Tue>\n"
902 ),
903 "{rendered}"
904 );
905 assert!(!rendered.contains(":DEADLINE:"), "{rendered}");
906 }
907
908 #[test]
909 fn a_legacy_date_property_is_promoted_to_a_planning_line() {
910 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";
913 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
914 assert_eq!(doc.headings[0].deadline(), Some("<2026-09-01 Tue>"));
915 let rendered = doc.headings[0].render();
916 assert!(
917 rendered.contains("\nDEADLINE: <2026-09-01 Tue>\n"),
918 "{rendered}"
919 );
920 assert!(!rendered.contains(":DEADLINE:"), "{rendered}");
921 }
922
923 #[test]
924 fn a_line_that_only_looks_like_planning_is_left_as_body() {
925 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";
926 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
927 assert_eq!(doc.headings[0].deadline(), None);
928 assert_eq!(
929 doc.headings[0].body,
930 "DEADLINE: is discussed in the design note."
931 );
932 }
933
934 #[test]
935 fn a_legacy_tags_property_moves_to_the_name_org_leaves_alone() {
936 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";
939 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
940 let h = &doc.headings[0];
941 assert_eq!(h.tags(), vec!["needs-review", "perf"]);
942 let rendered = h.render();
943 assert!(
944 rendered.contains(":VISSUE_TAGS: needs-review,perf"),
945 "{rendered}"
946 );
947 assert!(!rendered.contains(":TAGS:"), "{rendered}");
948 }
949
950 #[test]
951 fn bodies_end_at_the_next_heading() {
952 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";
953 let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
954 assert_eq!(doc.headings.len(), 2);
955 assert_eq!(doc.headings[0].body, "First body.");
956 assert_eq!(doc.headings[1].body, "Second body.\nMulti.");
957 }
958
959 #[test]
960 fn logbook_survives_a_document_round_trip() {
961 let mut h = sample_heading();
962 h.logbook = vec![LogEntry {
963 timestamp: "[2026-04-26 Sun 09:15]".into(),
964 from_state: Some("TODO".into()),
965 to_state: Some("STARTED".into()),
966 note: None,
967 raw: None,
968 }];
969 let mut content = String::from("#+TITLE: sample issues\n\n");
970 content.push_str(&h.render());
971 let parsed = IssueDoc::parse("sample", PathBuf::from("/tmp/x.org"), &content).unwrap();
972 assert_eq!(parsed.headings[0].logbook.len(), 1);
973 assert_eq!(
974 parsed.headings[0].logbook[0].from_state.as_deref(),
975 Some("TODO")
976 );
977 }
978
979 #[test]
980 fn write_then_reread_finds_the_heading() {
981 let dir = tempfile::tempdir().unwrap();
982 let path = dir.path().join("Software/sample/issues.org");
983 IssueDoc {
984 project: "sample".into(),
985 path: path.clone(),
986 preamble: default_preamble("sample"),
987 headings: vec![sample_heading()],
988 }
989 .write()
990 .unwrap();
991 let parsed = IssueDoc::parse_file("sample", &path).unwrap();
992 assert_eq!(parsed.headings[0].id, "sample-abc1");
993 }
994
995 #[test]
996 fn write_preserves_the_existing_preamble_and_property_order() {
997 let dir = tempfile::tempdir().unwrap();
998 let path = dir.path().join("Software/sample/issues.org");
999 fs::create_dir_all(path.parent().unwrap()).unwrap();
1000 fs::write(
1001 &path,
1002 "#+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",
1003 )
1004 .unwrap();
1005
1006 let mut doc = IssueDoc::parse_file("sample", &path).unwrap();
1007 doc.headings[0].priority = 'B';
1008 doc.write().unwrap();
1009 let written = fs::read_to_string(&path).unwrap();
1010
1011 assert!(written.contains("#+FILETAGS: :issues:sample:"), "{written}");
1012 assert!(written.contains("#+STATUS: Active"), "{written}");
1013 assert!(
1014 written.find(":TYPE:").unwrap() < written.find(":PARENT:").unwrap(),
1015 "{written}"
1016 );
1017 }
1018
1019 #[test]
1020 fn an_empty_document_writes_the_house_preamble() {
1021 let dir = tempfile::tempdir().unwrap();
1022 let path = dir.path().join("Software/sample/issues.org");
1023 IssueDoc::empty("sample", path.clone()).write().unwrap();
1024 let written = fs::read_to_string(&path).unwrap();
1025 for expected in [
1026 "#+TITLE: sample issues",
1027 "#+CATEGORY: sample",
1030 "#+FILETAGS: :issues:sample:",
1031 "#+DATE:",
1032 "#+STATUS: Active",
1033 TODO_HEADER,
1034 ] {
1035 assert!(written.contains(expected), "missing {expected}: {written}");
1036 }
1037 }
1038
1039 #[test]
1040 fn generated_ids_are_unique_and_sized() {
1041 let existing = vec!["p-aaaa".to_string()];
1042 let id = generate_id("p", &existing, 4).unwrap();
1043 assert!(id.starts_with("p-"));
1044 assert!(!existing.contains(&id));
1045 assert_eq!(id.len(), 1 + 1 + 4);
1046 assert_eq!(generate_id("q", &[], 6).unwrap().len(), 1 + 1 + 6);
1047 }
1048
1049 #[test]
1050 fn a_full_suffix_space_is_an_error_and_not_a_panic() {
1051 let mut existing = Vec::new();
1054 for a in ID_ALPHABET {
1055 for b in ID_ALPHABET {
1056 existing.push(format!("p-{}{}", *a as char, *b as char));
1057 }
1058 }
1059 let err = generate_id("p", &existing, 2).unwrap_err();
1060 assert!(err.to_string().contains("id_length"), "{err}");
1061 existing.pop();
1063 assert!(generate_id("p", &existing, 2).is_ok());
1064 }
1065
1066 #[test]
1067 fn projects_are_discovered_under_the_configured_prefix() {
1068 let dir = tempfile::tempdir().unwrap();
1069 let layout = Layout::new(dir.path(), "tracker");
1070 for project in ["beta", "alpha"] {
1071 IssueDoc::empty(project, layout.project_issues_path(project))
1072 .write()
1073 .unwrap();
1074 }
1075 assert_eq!(list_projects(&layout).unwrap(), vec!["alpha", "beta"]);
1076 assert!(list_projects(&Layout::new(dir.path(), DEFAULT_PREFIX))
1077 .unwrap()
1078 .is_empty());
1079 }
1080
1081 #[test]
1082 fn project_case_resolves_to_the_directory_on_disk() {
1083 let dir = tempfile::tempdir().unwrap();
1084 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
1085 IssueDoc::empty("MixedCase", layout.project_issues_path("MixedCase"))
1086 .write()
1087 .unwrap();
1088 assert_eq!(
1089 resolve_existing_project_case(&layout, "mixedcase").unwrap(),
1090 "MixedCase"
1091 );
1092 assert_eq!(
1093 resolve_existing_project_case(&layout, "brand-new").unwrap(),
1094 "brand-new"
1095 );
1096 }
1097
1098 #[test]
1099 fn project_context_file_is_found_by_walking_up() {
1100 let dir = tempfile::tempdir().unwrap();
1101 let nested = dir.path().join("a/b/c");
1102 fs::create_dir_all(&nested).unwrap();
1103 fs::write(
1104 dir.path().join(".project-ctx.toml"),
1105 "[project]\nname = \"demoproj\"\n",
1106 )
1107 .unwrap();
1108 assert_eq!(
1109 detect_project_from_ctx(&nested).as_deref(),
1110 Some("demoproj")
1111 );
1112 let empty = tempfile::tempdir().unwrap();
1113 assert!(detect_project_from_ctx(empty.path()).is_none());
1114 }
1115
1116 fn ids(content: &str) -> Vec<&str> {
1117 org_ids(content).collect()
1118 }
1119
1120 #[test]
1121 fn a_drawer_under_a_headline_defines_an_id() {
1122 assert_eq!(
1123 ids("* TODO [#B] a title\n:PROPERTIES:\n:ID: atlas-1a2b\n:END:\n"),
1124 ["atlas-1a2b"]
1125 );
1126 }
1127
1128 #[test]
1129 fn a_drawer_under_the_planning_line_defines_an_id() {
1130 let text = concat!(
1131 "* TODO a title\n",
1132 "DEADLINE: <2026-05-15 Fri>\n",
1133 ":PROPERTIES:\n",
1134 ":ID: atlas-1a2b\n",
1135 ":END:\n",
1136 );
1137 assert_eq!(ids(text), ["atlas-1a2b"]);
1138 }
1139
1140 #[test]
1141 fn a_logbook_beside_the_properties_does_not_hide_it() {
1142 let text = concat!(
1143 "* TODO a title\n",
1144 ":LOGBOOK:\n",
1145 "- claimed by worker-1\n",
1146 ":END:\n",
1147 ":PROPERTIES:\n",
1148 ":ID: atlas-1a2b\n",
1149 ":END:\n",
1150 );
1151 assert_eq!(ids(text), ["atlas-1a2b"]);
1152 }
1153
1154 #[test]
1155 fn an_id_quoted_in_a_body_defines_nothing() {
1156 let text = concat!(
1158 "* TODO a title\n",
1159 ":PROPERTIES:\n",
1160 ":ID: atlas-1a2b\n",
1161 ":END:\n",
1162 "\n",
1163 "The heading I was handed reads:\n",
1164 ":PROPERTIES:\n",
1165 ":ID: ghost-9999\n",
1166 ":END:\n",
1167 );
1168 assert_eq!(
1169 ids(text),
1170 ["atlas-1a2b"],
1171 "a report that quotes an id defined it"
1172 );
1173 }
1174
1175 #[test]
1176 fn a_bare_id_line_in_a_body_defines_nothing() {
1177 let text = concat!(
1178 "* TODO a title\n",
1179 ":PROPERTIES:\n",
1180 ":ID: atlas-1a2b\n",
1181 ":END:\n",
1182 "\n",
1183 "Compare with :ID: ghost-9999 in the other file.\n",
1184 ":ID: ghost-8888\n",
1185 );
1186 assert_eq!(ids(text), ["atlas-1a2b"]);
1187 }
1188
1189 #[test]
1190 fn a_file_level_drawer_defines_an_id() {
1191 let text = concat!(
1193 "#+TITLE: atlas issues\n",
1194 "\n",
1195 ":PROPERTIES:\n",
1196 ":ID: the-file-itself\n",
1197 ":END:\n",
1198 "\n",
1199 "* TODO a title\n",
1200 ":PROPERTIES:\n",
1201 ":ID: atlas-1a2b\n",
1202 ":END:\n",
1203 );
1204 assert_eq!(ids(text), ["the-file-itself", "atlas-1a2b"]);
1205 }
1206
1207 #[test]
1208 fn every_headline_depth_opens_a_drawer_site() {
1209 let text = concat!(
1210 "* TODO a title\n",
1211 ":PROPERTIES:\n",
1212 ":ID: atlas-1a2b\n",
1213 ":END:\n",
1214 "** A sub-heading someone wrote by hand\n",
1215 ":PROPERTIES:\n",
1216 ":ID: atlas-3c4d\n",
1217 ":END:\n",
1218 );
1219 assert_eq!(ids(text), ["atlas-1a2b", "atlas-3c4d"]);
1220 }
1221
1222 #[test]
1223 fn a_planning_keyword_needs_its_colon() {
1224 assert!(is_planning_line("DEADLINE: <2026-05-15 Fri>"));
1225 assert!(is_planning_line("CLOSED: [2026-05-15 Fri]"));
1226 assert!(!is_planning_line("DEADLINES slipped again"));
1228 assert!(!is_planning_line("SCHEDULED work for the week"));
1229 }
1230
1231 #[test]
1232 fn prose_that_opens_like_a_planning_line_still_ends_the_drawer_site() {
1233 let text = concat!(
1237 "* TODO a title\n",
1238 ":PROPERTIES:\n",
1239 ":ID: atlas-1a2b\n",
1240 ":END:\n",
1241 "\n",
1242 "DEADLINE: is discussed in the design note.\n",
1243 ":PROPERTIES:\n",
1244 ":ID: ghost-9999\n",
1245 ":END:\n",
1246 );
1247 assert_eq!(ids(text), ["atlas-1a2b"]);
1248 }
1249
1250 #[test]
1251 fn a_headline_needs_a_space_after_its_stars() {
1252 assert!(is_headline("* TODO a title"));
1253 assert!(is_headline("*** deeper"));
1254 assert!(!is_headline("**bold** at the start of a line"));
1255 assert!(!is_headline("not a headline"));
1256 }
1257}