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