1use crate::model::TODO_KEYWORDS;
12
13pub const PLANNING_KEYS: &[&str] = &["CLOSED", "SCHEDULED", "DEADLINE"];
15
16pub fn is_org_tag_char(c: char) -> bool {
18 c.is_alphanumeric() || matches!(c, '_' | '@' | '#' | '%')
19}
20
21pub fn is_headline(line: &str) -> bool {
27 let stars = line.len() - line.trim_start_matches('*').len();
28 stars > 0 && line[stars..].starts_with(' ')
29}
30
31pub fn is_top_level_headline(line: &str) -> bool {
33 line.starts_with("* ")
34}
35
36pub fn opens_a_drawer(trimmed: &str) -> bool {
38 trimmed.len() > 2
39 && trimmed.starts_with(':')
40 && trimmed.ends_with(':')
41 && !trimmed.eq_ignore_ascii_case(":END:")
42 && !trimmed[1..trimmed.len() - 1].contains(char::is_whitespace)
43}
44
45#[derive(Debug, Default, Clone)]
51pub struct BlockNest {
52 depth: usize,
53}
54
55impl BlockNest {
56 pub fn new() -> Self {
58 Self::default()
59 }
60
61 pub fn inside(&self) -> bool {
63 self.depth > 0
64 }
65
66 pub fn observe(&mut self, line: &str) -> bool {
69 let trimmed = line.trim_start();
70 if is_block_end(trimmed) {
71 self.depth = self.depth.saturating_sub(1);
72 return true;
73 }
74 if is_block_begin(trimmed) {
75 self.depth += 1;
76 return true;
77 }
78 self.depth > 0
79 }
80}
81
82#[derive(Debug, Default, Clone)]
89pub struct OrgScan {
90 blocks: BlockNest,
91 results: ResultsState,
92}
93
94#[derive(Debug, Clone, Default, PartialEq, Eq)]
95enum ResultsState {
96 #[default]
97 Out,
98 Awaiting,
100 ViaBlock,
102 Drawer,
103 Table,
104 FixedWidth,
105 List,
106 Headline {
107 stars: usize,
108 },
109}
110
111impl OrgScan {
112 pub fn new() -> Self {
114 Self::default()
115 }
116
117 pub fn inside(&self) -> bool {
119 self.blocks.inside() || !matches!(self.results, ResultsState::Out)
120 }
121
122 pub fn observe(&mut self, line: &str) -> bool {
125 let trimmed = line.trim_start();
126
127 if self.blocks.inside() || is_block_end(trimmed) {
128 let in_block = self.blocks.observe(line);
129 if !self.blocks.inside() && matches!(self.results, ResultsState::ViaBlock) {
130 self.results = ResultsState::Out;
131 }
132 return in_block || matches!(self.results, ResultsState::ViaBlock);
133 }
134
135 if is_results_keyword(line.trim()) {
136 self.results = ResultsState::Awaiting;
137 return true;
138 }
139
140 if is_block_begin(trimmed) {
141 if matches!(self.results, ResultsState::Awaiting) {
142 self.results = ResultsState::ViaBlock;
143 }
144 return self.blocks.observe(line);
145 }
146
147 match self.results {
148 ResultsState::Out => false,
149 ResultsState::ViaBlock => {
150 self.results = ResultsState::Out;
151 false
152 }
153 ResultsState::Awaiting => {
154 if line.trim().is_empty() {
155 return true;
156 }
157 self.start_result_element(line)
158 }
159 ResultsState::Drawer => {
160 if line.trim().eq_ignore_ascii_case(":END:") {
161 self.results = ResultsState::Out;
162 }
163 true
164 }
165 ResultsState::Table => {
166 if is_org_table_line(line.trim()) {
167 true
168 } else {
169 self.results = ResultsState::Out;
170 self.observe(line)
171 }
172 }
173 ResultsState::FixedWidth => {
174 if is_fixed_width_line(line) {
175 true
176 } else {
177 self.results = ResultsState::Out;
178 self.observe(line)
179 }
180 }
181 ResultsState::List => {
182 if line.trim().is_empty() {
183 self.results = ResultsState::Out;
184 return true;
185 }
186 if is_org_list_line(line) || is_list_continuation(line) {
187 true
188 } else {
189 self.results = ResultsState::Out;
190 self.observe(line)
191 }
192 }
193 ResultsState::Headline { stars } => {
194 if is_headline(line) {
195 let n = headline_stars(line);
196 if n > 0 && n <= stars {
197 self.results = ResultsState::Out;
198 return self.observe(line);
199 }
200 }
201 true
202 }
203 }
204 }
205
206 fn start_result_element(&mut self, line: &str) -> bool {
207 let trimmed = line.trim();
208 if opens_a_drawer(trimmed) {
209 self.results = ResultsState::Drawer;
210 return true;
211 }
212 if is_org_table_line(trimmed) {
213 self.results = ResultsState::Table;
214 return true;
215 }
216 if is_fixed_width_line(line) {
217 self.results = ResultsState::FixedWidth;
218 return true;
219 }
220 if is_org_list_line(line) {
221 self.results = ResultsState::List;
222 return true;
223 }
224 if is_headline(line) {
225 self.results = ResultsState::Headline {
226 stars: headline_stars(line),
227 };
228 return true;
229 }
230 self.results = ResultsState::Out;
233 true
234 }
235}
236
237pub fn is_results_keyword(trimmed: &str) -> bool {
239 let Some(rest) = strip_hash_plus(trimmed.trim()) else {
240 return false;
241 };
242 let Some(after) =
243 strip_keyword_prefix(rest, "RESULTS").or_else(|| strip_keyword_prefix(rest, "RESULT"))
244 else {
245 return false;
246 };
247 let after = after.trim_start();
248 if after.starts_with(':') {
249 return true;
250 }
251 if after.starts_with('[') {
252 return after.contains(':');
253 }
254 false
255}
256
257pub fn is_babel_call(trimmed: &str) -> bool {
259 let Some(rest) = strip_hash_plus(trimmed.trim()) else {
260 return false;
261 };
262 strip_keyword_prefix(rest, "CALL").is_some_and(|after| after.starts_with(':'))
263}
264
265pub fn is_affiliated_keyword(trimmed: &str) -> bool {
267 let Some(rest) = strip_hash_plus(trimmed.trim()) else {
268 return false;
269 };
270 let Some((key, _)) = rest.split_once(':') else {
271 return false;
272 };
273 let key = key.trim();
274 if starts_ignore_ascii(key, "ATTR_") {
275 return true;
276 }
277 matches!(
278 key.to_ascii_uppercase().as_str(),
279 "CAPTION"
280 | "DATA"
281 | "HEADER"
282 | "HEADERS"
283 | "LABEL"
284 | "NAME"
285 | "PLOT"
286 | "RESNAME"
287 | "RESULT"
288 | "RESULTS"
289 | "SOURCE"
290 | "SRCNAME"
291 | "TBLNAME"
292 )
293}
294
295#[derive(Debug, Clone, PartialEq, Eq)]
297pub struct SrcBlockHead<'a> {
298 pub lang: &'a str,
300 pub switches: &'a str,
302 pub headers: &'a str,
304}
305
306pub fn parse_src_begin(line: &str) -> Option<SrcBlockHead<'_>> {
308 let rest = strip_hash_plus(line.trim_start())?;
309 let after = strip_keyword_prefix(rest, "BEGIN_SRC")?;
310 let after = after.trim_start();
311 if after.is_empty() {
312 return None;
313 }
314 let (lang, rest) = first_word(after).unwrap_or((after, ""));
315 let rest = rest.trim_start();
316 let (switches, headers) = match rest.find(':') {
317 Some(i) => (rest[..i].trim(), rest[i..].trim()),
318 None => (rest.trim(), ""),
319 };
320 Some(SrcBlockHead {
321 lang,
322 switches,
323 headers,
324 })
325}
326
327pub fn parse_header_args(s: &str) -> Vec<(String, String)> {
329 let mut out = Vec::new();
330 let mut rest = s.trim();
331 while let Some(idx) = rest.find(':') {
332 rest = rest[idx + 1..].trim_start();
333 if rest.is_empty() {
334 break;
335 }
336 let (key, after) = match rest.find(char::is_whitespace) {
337 Some(i) => (&rest[..i], rest[i..].trim_start()),
338 None => (rest, ""),
339 };
340 if key.is_empty() {
341 break;
342 }
343 let (value, next) = next_header_value(after);
344 out.push((key.to_string(), value.to_string()));
345 rest = next;
346 }
347 out
348}
349
350fn next_header_value(s: &str) -> (&str, &str) {
351 if s.is_empty() || s.starts_with(':') {
352 return ("", s);
353 }
354 let bytes = s.as_bytes();
355 let mut i = 0;
356 while i < bytes.len() {
357 if bytes[i] == b':' && (i == 0 || bytes[i - 1].is_ascii_whitespace()) {
358 break;
359 }
360 i += 1;
361 }
362 while i > 0 && !s.is_char_boundary(i) {
364 i -= 1;
365 }
366 (s[..i].trim(), s[i..].trim_start())
367}
368
369pub fn noweb_refs(body: &str) -> Vec<&str> {
371 let mut refs = Vec::new();
372 let mut rest = body;
373 while let Some(start) = rest.find("<<") {
374 let after = &rest[start + 2..];
375 let Some(end) = after.find(">>") else {
376 break;
377 };
378 let inner = after[..end].trim();
379 if !inner.is_empty() && !inner.contains('\n') && !refs.contains(&inner) {
380 refs.push(inner);
381 }
382 rest = &after[end + 2..];
383 }
384 refs
385}
386
387pub fn inline_src_spans(text: &str) -> Vec<(&str, &str, &str)> {
389 let mut out = Vec::new();
390 let mut rest = text;
391 while let Some(idx) = rest.find("src_") {
392 let after = &rest[idx + 4..];
393 let lang_len = after
394 .find(|c: char| c.is_whitespace() || c == '[' || c == '{')
395 .unwrap_or(after.len());
396 if lang_len == 0 {
397 rest = &after[1.min(after.len())..];
398 continue;
399 }
400 let lang = &after[..lang_len];
401 let mut tail = &after[lang_len..];
402 let mut headers = "";
403 if let Some(inner) = tail.strip_prefix('[') {
404 let Some(end) = inner.find(']') else {
405 rest = tail;
406 continue;
407 };
408 headers = &inner[..end];
409 tail = &inner[end + 1..];
410 }
411 let Some(inner) = tail.strip_prefix('{') else {
412 rest = tail;
413 continue;
414 };
415 let Some(end) = inner.find('}') else {
416 rest = tail;
417 continue;
418 };
419 out.push((lang, headers, &inner[..end]));
420 rest = &inner[end + 1..];
421 }
422 out
423}
424
425pub fn inline_call_names(text: &str) -> Vec<&str> {
427 let mut out = Vec::new();
428 let mut rest = text;
429 while let Some(idx) = rest.find("call_") {
430 let after = &rest[idx + 5..];
431 let name_len = after
432 .find(|c: char| c.is_whitespace() || c == '[' || c == '(')
433 .unwrap_or(after.len());
434 if name_len == 0 {
435 rest = &after[1.min(after.len())..];
436 continue;
437 }
438 let name = &after[..name_len];
439 let tail = &after[name_len..];
440 if tail.starts_with('(') || tail.starts_with('[') {
441 out.push(name);
442 }
443 rest = tail;
444 }
445 out
446}
447
448fn strip_hash_plus(trimmed: &str) -> Option<&str> {
449 trimmed.strip_prefix("#+")
450}
451
452fn strip_keyword_prefix<'a>(s: &'a str, keyword: &str) -> Option<&'a str> {
453 if s.len() >= keyword.len()
454 && s.is_char_boundary(keyword.len())
455 && s[..keyword.len()].eq_ignore_ascii_case(keyword)
456 {
457 Some(&s[keyword.len()..])
458 } else {
459 None
460 }
461}
462
463fn headline_stars(line: &str) -> usize {
464 line.len() - line.trim_start_matches('*').len()
465}
466
467fn is_org_table_line(trimmed: &str) -> bool {
468 trimmed.starts_with('|')
469}
470
471fn is_fixed_width_line(line: &str) -> bool {
472 let trimmed = line.trim_start();
473 matches!(
474 trimmed.as_bytes(),
475 [b':'] | [b':', b' ', ..] | [b':', b'\t', ..]
476 ) && !opens_a_drawer(trimmed)
477 && !trimmed.eq_ignore_ascii_case(":END:")
478}
479
480fn is_org_list_line(line: &str) -> bool {
481 if is_headline(line) {
482 return false;
483 }
484 let trimmed = line.trim_start();
485 if trimmed.starts_with("- ") || trimmed.starts_with("+ ") {
486 return true;
487 }
488 let Some((token, rest)) = first_word(trimmed) else {
489 return false;
490 };
491 let rest = rest.trim_start();
492 if rest.is_empty() && !token.ends_with('.') && !token.ends_with(')') {
493 return false;
494 }
495 let bare = token.trim_end_matches(['.', ')']);
496 if bare.is_empty() || bare == token {
497 return false;
498 }
499 bare.chars().all(|c| c.is_ascii_digit())
500 || (bare.len() == 1 && bare.chars().all(|c| c.is_ascii_alphabetic()))
501}
502
503fn is_list_continuation(line: &str) -> bool {
504 !is_headline(line)
505 && (line.starts_with(' ') || line.starts_with('\t'))
506 && !line.trim().is_empty()
507}
508
509fn is_block_begin(trimmed: &str) -> bool {
510 let Some(rest) = trimmed.strip_prefix("#+") else {
511 return false;
512 };
513 starts_ignore_ascii(rest, "BEGIN_") || starts_ignore_ascii(rest, "BEGIN:")
514}
515
516fn is_block_end(trimmed: &str) -> bool {
517 let Some(rest) = trimmed.strip_prefix("#+") else {
518 return false;
519 };
520 starts_ignore_ascii(rest, "END_") || starts_ignore_ascii(rest, "END:")
521}
522
523fn starts_ignore_ascii(s: &str, prefix: &str) -> bool {
524 s.len() >= prefix.len()
525 && s.is_char_boundary(prefix.len())
526 && s[..prefix.len()].eq_ignore_ascii_case(prefix)
527}
528
529pub fn todo_keywords_from_preamble(preamble: &str) -> Vec<String> {
535 todo_keywords_from_lines(&preamble.lines().collect::<Vec<_>>())
536}
537
538#[derive(Debug, Clone, PartialEq, Eq)]
542pub struct TodoSequence {
543 pub open: Vec<String>,
545 pub done: Vec<String>,
547}
548
549impl Default for TodoSequence {
550 fn default() -> Self {
551 Self::house()
552 }
553}
554
555impl TodoSequence {
556 #[must_use]
558 pub fn house() -> Self {
559 Self {
560 open: ["TODO", "STARTED", "BLOCKED"].map(str::to_string).to_vec(),
561 done: ["DONE", "CANCELLED"].map(str::to_string).to_vec(),
562 }
563 }
564
565 #[must_use]
567 pub fn knows(&self, state: &str) -> bool {
568 self.open.iter().any(|k| k == state) || self.done.iter().any(|k| k == state)
569 }
570
571 #[must_use]
573 pub fn is_done(&self, state: &str) -> bool {
574 self.done.iter().any(|k| k == state)
575 }
576
577 #[must_use]
579 pub fn all(&self) -> Vec<String> {
580 self.open.iter().chain(self.done.iter()).cloned().collect()
581 }
582}
583
584#[must_use]
587pub fn todo_sequence_from_lines(lines: &[&str]) -> TodoSequence {
588 let mut seq = TodoSequence::house();
589 for line in lines {
590 let trimmed = line.trim();
591 let Some(rest) = ["TODO", "SEQ_TODO", "TYP_TODO"]
592 .iter()
593 .find_map(|name| strip_file_keyword(trimmed, name))
594 else {
595 continue;
596 };
597 let names: Vec<&str> = rest
598 .split_whitespace()
599 .map(|token| token.split('(').next().unwrap_or(token))
600 .filter(|name| !name.is_empty())
601 .collect();
602 let bar = names.iter().position(|n| *n == "|");
603 let done_from = bar.map_or(names.len().saturating_sub(1), |b| b + 1);
605 for (i, name) in names.iter().enumerate() {
606 if *name == "|" || seq.knows(name) {
607 continue;
608 }
609 if i >= done_from {
610 seq.done.push((*name).to_string());
611 } else {
612 seq.open.push((*name).to_string());
613 }
614 }
615 }
616 seq
617}
618
619#[must_use]
622pub fn has_repeater(value: &str) -> bool {
623 value
624 .split_whitespace()
625 .any(|tok| parse_repeater(tok.trim_end_matches('>')).is_some())
626}
627
628fn parse_repeater(tok: &str) -> Option<(&str, u32, char)> {
630 let (kind, rest) = if let Some(r) = tok.strip_prefix(".+") {
631 (".+", r)
632 } else if let Some(r) = tok.strip_prefix("++") {
633 ("++", r)
634 } else {
635 ("+", tok.strip_prefix('+')?)
636 };
637 let unit = rest.chars().last()?;
638 if !"hdwmy".contains(unit) {
639 return None;
640 }
641 let count: u32 = rest[..rest.len() - 1].parse().ok()?;
642 (count > 0).then_some((kind, count, unit))
643}
644
645fn add_interval(date: chrono::NaiveDate, count: u32, unit: char) -> Option<chrono::NaiveDate> {
646 use chrono::{Days, Months};
647 match unit {
648 'd' => date.checked_add_days(Days::new(u64::from(count))),
649 'w' => date.checked_add_days(Days::new(u64::from(count) * 7)),
650 'm' => date.checked_add_months(Months::new(count)),
651 'y' => date.checked_add_months(Months::new(count * 12)),
652 _ => Some(date),
654 }
655}
656
657#[must_use]
664pub fn shift_repeating_timestamp(value: &str, today: chrono::NaiveDate) -> Option<String> {
665 let trimmed = value.trim();
666 if trimmed.contains("--") {
667 return None;
668 }
669 let (open, close) = if trimmed.starts_with('<') && trimmed.ends_with('>') {
670 ('<', '>')
671 } else if trimmed.starts_with('[') && trimmed.ends_with(']') {
672 ('[', ']')
673 } else {
674 return None;
675 };
676 let inner = &trimmed[1..trimmed.len() - 1];
677 let tokens: Vec<&str> = inner.split_whitespace().collect();
678 let date_tok = tokens.first()?;
679 let mut date = chrono::NaiveDate::parse_from_str(date_tok, "%Y-%m-%d").ok()?;
680 let mut rest: Vec<String> = Vec::new();
681 let mut repeater = None;
682 for tok in &tokens[1..] {
683 if let Some(r) = parse_repeater(tok) {
684 repeater = Some(r);
685 rest.push((*tok).to_string());
686 } else if tok.chars().all(|c| c.is_ascii_alphabetic())
687 && tok.len() <= 3
688 && repeater.is_none()
689 {
690 } else {
692 rest.push((*tok).to_string());
693 }
694 }
695 let (kind, count, unit) = repeater?;
696 date = match kind {
697 "+" => add_interval(date, count, unit)?,
698 "++" => {
699 let mut next = add_interval(date, count, unit)?;
700 let mut guard = 0;
701 while next <= today && guard < 10_000 {
702 next = add_interval(next, count, unit)?;
703 guard += 1;
704 }
705 next
706 }
707 _ => add_interval(today, count, unit)?,
708 };
709 let mut out = format!("{open}{} {}", date.format("%Y-%m-%d"), date.format("%a"));
710 for tok in rest {
711 out.push(' ');
712 out.push_str(&tok);
713 }
714 out.push(close);
715 Some(out)
716}
717
718pub fn todo_keywords_from_lines(lines: &[&str]) -> Vec<String> {
720 let mut keywords: Vec<String> = TODO_KEYWORDS.iter().map(|s| (*s).to_string()).collect();
721 for line in lines {
722 let trimmed = line.trim();
723 let Some(rest) = strip_file_keyword(trimmed, "TODO") else {
724 continue;
725 };
726 for token in rest.split_whitespace() {
727 if token == "|" {
728 continue;
729 }
730 let name = token.split('(').next().unwrap_or(token);
731 if name.is_empty() {
732 continue;
733 }
734 if !keywords.iter().any(|k| k == name) {
735 keywords.push(name.to_string());
736 }
737 }
738 }
739 keywords
740}
741
742pub fn filetags_from_preamble(preamble: &str) -> Vec<String> {
744 tag_settings_from_preamble(preamble).filetags
745}
746
747#[derive(Debug, Clone, PartialEq, Eq)]
749pub struct TagSpec {
750 pub name: String,
752 pub key: Option<char>,
754}
755
756#[derive(Debug, Clone, PartialEq, Eq)]
758pub struct TagSettings {
759 pub filetags: Vec<String>,
761 pub declared: Vec<TagSpec>,
763 pub exclusive: Vec<Vec<String>>,
765 pub hierarchies: Vec<(String, Vec<String>)>,
767 pub select_tags: Vec<String>,
769 pub exclude_tags: Vec<String>,
771}
772
773impl Default for TagSettings {
774 fn default() -> Self {
775 Self {
776 filetags: Vec::new(),
777 declared: Vec::new(),
778 exclusive: Vec::new(),
779 hierarchies: Vec::new(),
780 select_tags: vec!["export".into()],
781 exclude_tags: vec!["noexport".into()],
782 }
783 }
784}
785
786impl TagSettings {
787 pub fn all_tags(&self, own: &[String]) -> Vec<String> {
791 let mut tags = own.to_vec();
792 for tag in &self.filetags {
793 if !tags.iter().any(|seen| seen == tag) {
794 tags.push(tag.clone());
795 }
796 }
797 tags
798 }
799
800 pub fn matches_query(&self, own: &[String], needle: &str) -> bool {
803 let needle_l = needle.to_lowercase();
804 if needle_l.is_empty() {
805 return false;
806 }
807 let all = self.all_tags(own);
808 if all.iter().any(|tag| tag.to_lowercase().contains(&needle_l)) {
809 return true;
810 }
811 for (group, members) in &self.hierarchies {
812 if !group.to_lowercase().contains(&needle_l) {
813 continue;
814 }
815 if members
816 .iter()
817 .any(|m| all.iter().any(|tag| tag.eq_ignore_ascii_case(m)))
818 {
819 return true;
820 }
821 }
822 false
823 }
824
825 pub fn heading_exportable(&self, own: &[String]) -> bool {
832 !own.iter().any(|tag| {
833 self.exclude_tags
834 .iter()
835 .any(|ex| ex.eq_ignore_ascii_case(tag))
836 })
837 }
838}
839
840pub fn tag_settings_from_preamble(preamble: &str) -> TagSettings {
842 let mut settings = TagSettings::default();
843 let mut saw_select = false;
844 let mut saw_exclude = false;
845 for line in preamble.lines() {
846 let trimmed = line.trim();
847 if let Some(rest) = strip_file_keyword(trimmed, "FILETAGS") {
848 for tag in rest.trim().trim_matches(':').split(':') {
849 let tag = tag.trim();
850 if !tag.is_empty()
851 && tag.chars().all(is_org_tag_char)
852 && !settings.filetags.iter().any(|t| t == tag)
853 {
854 settings.filetags.push(tag.to_string());
855 }
856 }
857 continue;
858 }
859 if let Some(rest) = strip_file_keyword(trimmed, "TAGS") {
860 apply_tags_line(&mut settings, rest);
861 continue;
862 }
863 if let Some(rest) = strip_file_keyword(trimmed, "SELECT_TAGS") {
864 settings.select_tags = split_keyword_tags(rest);
865 saw_select = true;
866 continue;
867 }
868 if let Some(rest) = strip_file_keyword(trimmed, "EXCLUDE_TAGS") {
869 settings.exclude_tags = split_keyword_tags(rest);
870 saw_exclude = true;
871 }
872 }
873 if !saw_select {
874 settings.select_tags = vec!["export".into()];
875 }
876 if !saw_exclude {
877 settings.exclude_tags = vec!["noexport".into()];
878 }
879 settings
880}
881
882fn split_keyword_tags(rest: &str) -> Vec<String> {
883 let mut tags = Vec::new();
884 for tag in rest
885 .split(|c: char| c.is_whitespace() || c == ':' || c == ',')
886 .map(str::trim)
887 .filter(|t| !t.is_empty())
888 {
889 if tag.chars().all(is_org_tag_char) && !tags.iter().any(|t| t == tag) {
890 tags.push(tag.to_string());
891 }
892 }
893 tags
894}
895
896fn apply_tags_line(settings: &mut TagSettings, rest: &str) {
897 let tokens = tokenize_tags_line(rest);
898 let mut i = 0;
899 while i < tokens.len() {
900 match tokens[i].as_str() {
901 "{" | "[" => {
902 let exclusive = tokens[i] == "{";
903 let closer = if exclusive { "}" } else { "]" };
904 i += 1;
905 let mut names: Vec<TagSpec> = Vec::new();
906 let mut hierarchy_at = None;
907 while i < tokens.len() && tokens[i] != closer {
908 if tokens[i] == ":" {
909 hierarchy_at = Some(names.len());
910 i += 1;
911 continue;
912 }
913 if let Some(spec) = parse_tag_token(&tokens[i]) {
914 names.push(spec);
915 }
916 i += 1;
917 }
918 if i < tokens.len() && tokens[i] == closer {
919 i += 1;
920 }
921 let names_only: Vec<String> = names.iter().map(|s| s.name.clone()).collect();
922 if let Some(split) = hierarchy_at {
923 if split >= 1 {
924 let group = names[0].name.clone();
925 let members: Vec<String> = names_only.into_iter().skip(split).collect();
926 settings.hierarchies.push((group, members));
927 }
928 } else if exclusive && names_only.len() >= 2 {
929 settings.exclusive.push(names_only);
930 }
931 for spec in names {
932 if !settings.declared.iter().any(|d| d.name == spec.name) {
933 settings.declared.push(spec);
934 }
935 }
936 }
937 "\\n" => i += 1,
938 other => {
939 if let Some(spec) = parse_tag_token(other)
940 && !settings.declared.iter().any(|d| d.name == spec.name)
941 {
942 settings.declared.push(spec);
943 }
944 i += 1;
945 }
946 }
947 }
948}
949
950fn tokenize_tags_line(rest: &str) -> Vec<String> {
951 let mut tokens = Vec::new();
952 let chars: Vec<char> = rest.chars().collect();
953 let mut i = 0;
954 while i < chars.len() {
955 let c = chars[i];
956 if c.is_whitespace() {
957 i += 1;
958 continue;
959 }
960 if matches!(c, '{' | '}' | '[' | ']' | ':') {
961 tokens.push(c.to_string());
962 i += 1;
963 continue;
964 }
965 if c == '\\' && chars.get(i + 1) == Some(&'n') {
966 tokens.push("\\n".into());
967 i += 2;
968 continue;
969 }
970 let start = i;
971 while i < chars.len()
972 && !chars[i].is_whitespace()
973 && !matches!(chars[i], '{' | '}' | '[' | ']' | ':')
974 {
975 i += 1;
976 }
977 tokens.push(chars[start..i].iter().collect());
978 }
979 tokens
980}
981
982fn parse_tag_token(token: &str) -> Option<TagSpec> {
983 let token = token.trim();
984 if token.is_empty() {
985 return None;
986 }
987 if let Some(name) = token.strip_suffix(')')
988 && let Some((name, key)) = name.rsplit_once('(')
989 {
990 let name = name.trim();
991 let key = key.trim();
992 if !name.is_empty() && name.chars().all(is_org_tag_char) && key.chars().count() == 1 {
993 return Some(TagSpec {
994 name: name.to_string(),
995 key: key.chars().next(),
996 });
997 }
998 }
999 if token.chars().all(is_org_tag_char) {
1000 return Some(TagSpec {
1001 name: token.to_string(),
1002 key: None,
1003 });
1004 }
1005 None
1006}
1007
1008pub const HOUSE_TAGS_LINES: &[&str] = &[
1010 "#+TAGS: { bug(b) feature(f) task(t) chore(c) plan(p) }",
1011 "#+TAGS: docs(d) perf ignore ARCHIVE",
1012];
1013
1014pub const HOUSE_PRIORITIES_LINE: &str = "#+PRIORITIES: A C C";
1019
1020pub const PROTOCOL_VERSION: u32 = 1;
1028
1029pub const PROTOCOL_KEYWORD: &str = "VISSUE";
1031
1032pub fn protocol_from_preamble(preamble: &str) -> Option<u32> {
1034 for line in preamble.lines() {
1035 if let Some(n) = protocol_from_keyword_line(line) {
1036 return Some(n);
1037 }
1038 }
1039 None
1040}
1041
1042fn protocol_from_keyword_line(line: &str) -> Option<u32> {
1043 let rest = strip_file_keyword(line.trim(), PROTOCOL_KEYWORD)?;
1044 let mut parts = rest.split_whitespace();
1045 let first = parts.next()?;
1046 if first.eq_ignore_ascii_case("protocol") {
1047 parts.next()?.parse().ok()
1048 } else {
1049 first
1050 .strip_prefix("protocol=")
1051 .unwrap_or(first)
1052 .parse()
1053 .ok()
1054 }
1055}
1056
1057fn protocol_stamp_line() -> String {
1058 format!("#+{PROTOCOL_KEYWORD}: {PROTOCOL_VERSION}")
1059}
1060
1061#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1067pub struct PrioritySpec {
1068 pub highest: char,
1070 pub lowest: char,
1072 pub default: char,
1074}
1075
1076impl Default for PrioritySpec {
1077 fn default() -> Self {
1078 Self {
1079 highest: 'A',
1080 lowest: 'C',
1081 default: 'C',
1082 }
1083 }
1084}
1085
1086impl PrioritySpec {
1087 pub fn contains(self, cookie: char) -> bool {
1089 let (lo, hi) = if self.highest <= self.lowest {
1090 (self.highest, self.lowest)
1091 } else {
1092 (self.lowest, self.highest)
1093 };
1094 cookie >= lo && cookie <= hi
1095 }
1096}
1097
1098pub fn priorities_from_preamble(preamble: &str) -> PrioritySpec {
1100 for line in preamble.lines() {
1101 let Some(rest) = strip_file_keyword(line.trim(), "PRIORITIES") else {
1102 continue;
1103 };
1104 let mut toks = rest.split_whitespace();
1105 let (Some(h), Some(l), Some(d)) = (toks.next(), toks.next(), toks.next()) else {
1106 continue;
1107 };
1108 let (Some(highest), Some(lowest), Some(default)) =
1109 (h.chars().next(), l.chars().next(), d.chars().next())
1110 else {
1111 continue;
1112 };
1113 return PrioritySpec {
1114 highest,
1115 lowest,
1116 default,
1117 };
1118 }
1119 PrioritySpec::default()
1120}
1121
1122pub fn is_gcal_event_id(id: &str) -> bool {
1124 let id = id.trim();
1125 let Some((left, right)) = id.split_once('/') else {
1126 return false;
1127 };
1128 !left.is_empty()
1129 && !right.is_empty()
1130 && !left.contains(char::is_whitespace)
1131 && !right.contains(char::is_whitespace)
1132}
1133
1134pub fn org_property_is_set(
1136 properties: &std::collections::BTreeMap<String, String>,
1137 key: &str,
1138) -> bool {
1139 properties.get(key).is_some_and(|raw| {
1140 let v = raw.trim();
1141 !v.is_empty() && !v.eq_ignore_ascii_case("nil") && v != "0"
1142 })
1143}
1144
1145pub fn merge_setupfile_settings(preamble: &str, base_dir: Option<&std::path::Path>) -> String {
1150 let mut seen = std::collections::HashSet::new();
1151 let mut out = String::new();
1152 collect_setupfile_settings(preamble, base_dir, &mut seen, 0, &mut out);
1153 if !out.is_empty() && !out.ends_with('\n') {
1154 out.push('\n');
1155 }
1156 out.push_str(preamble);
1157 out
1158}
1159
1160fn collect_setupfile_settings(
1161 text: &str,
1162 base_dir: Option<&std::path::Path>,
1163 seen: &mut std::collections::HashSet<std::path::PathBuf>,
1164 depth: u8,
1165 out: &mut String,
1166) {
1167 if depth > 16 {
1168 return;
1169 }
1170 for line in text.lines() {
1171 let Some(rest) = strip_file_keyword(line.trim(), "SETUPFILE") else {
1172 continue;
1173 };
1174 let spec = rest.trim().trim_matches('"').trim_matches('\'').trim();
1175 if spec.is_empty()
1176 || spec.contains("://")
1177 || spec.starts_with("http:")
1178 || spec.starts_with("https:")
1179 {
1180 continue;
1181 }
1182 let path = match base_dir {
1183 Some(dir) => dir.join(spec),
1184 None => std::path::PathBuf::from(spec),
1185 };
1186 let key = path.canonicalize().unwrap_or(path.clone());
1187 if !seen.insert(key) {
1188 continue;
1189 }
1190 let Ok(body) = std::fs::read_to_string(&path) else {
1191 continue;
1192 };
1193 let keywords: String = body
1194 .lines()
1195 .filter(|l| l.trim_start().starts_with("#+"))
1196 .collect::<Vec<_>>()
1197 .join("\n");
1198 if !keywords.is_empty() {
1199 out.push_str(&keywords);
1200 out.push('\n');
1201 }
1202 collect_setupfile_settings(&keywords, path.parent(), seen, depth + 1, out);
1203 }
1204}
1205
1206pub fn preamble_has_keyword(preamble: &str, name: &str) -> bool {
1208 preamble
1209 .lines()
1210 .any(|line| strip_file_keyword(line.trim(), name).is_some())
1211}
1212
1213pub fn ensure_org_preamble(preamble: &str, project: &str) -> String {
1222 if preamble.trim().is_empty() {
1223 return preamble.to_string();
1224 }
1225 let mut lines: Vec<String> = preamble.lines().map(str::to_string).collect();
1226 let insert_at = lines
1227 .iter()
1228 .position(|line| strip_file_keyword(line.trim(), "TITLE").is_some())
1229 .map(|i| i + 1)
1230 .unwrap_or(0);
1231 let mut extra = Vec::new();
1232 if !preamble_has_keyword(preamble, "CATEGORY") {
1233 extra.push(format!("#+CATEGORY: {project}"));
1234 }
1235 if !preamble_has_keyword(preamble, "FILETAGS") {
1236 extra.push(format!("#+FILETAGS: :issues:{project}:noexport:"));
1237 }
1238 if !preamble_has_keyword(preamble, "TAGS") {
1239 extra.extend(HOUSE_TAGS_LINES.iter().map(|s| (*s).to_string()));
1240 }
1241 if !preamble_has_keyword(preamble, "EXCLUDE_TAGS") {
1242 extra.push("#+EXCLUDE_TAGS: noexport".to_string());
1243 }
1244 if !preamble_has_keyword(preamble, "SELECT_TAGS") {
1245 extra.push("#+SELECT_TAGS: export".to_string());
1246 }
1247 if !preamble_has_keyword(preamble, "PRIORITIES") {
1248 extra.push(HOUSE_PRIORITIES_LINE.to_string());
1249 }
1250 for (offset, line) in extra.into_iter().enumerate() {
1251 lines.insert(insert_at + offset, line);
1252 }
1253 ensure_filetags_has_noexport(&mut lines);
1254 ensure_protocol_stamp(&mut lines);
1255 let out = lines.join("\n");
1256 if out == preamble {
1257 preamble.to_string()
1258 } else {
1259 out
1260 }
1261}
1262
1263fn ensure_protocol_stamp(lines: &mut Vec<String>) {
1264 let stamp = protocol_stamp_line();
1265 for line in lines.iter_mut() {
1266 if strip_file_keyword(line.trim(), PROTOCOL_KEYWORD).is_none() {
1267 continue;
1268 }
1269 match protocol_from_keyword_line(line) {
1270 Some(n) if n >= PROTOCOL_VERSION => {}
1271 _ => *line = stamp,
1272 }
1273 return;
1274 }
1275 let insert_at = lines
1276 .iter()
1277 .position(|line| strip_file_keyword(line.trim(), "TITLE").is_some())
1278 .map(|i| i + 1)
1279 .unwrap_or(0);
1280 lines.insert(insert_at, stamp);
1281}
1282
1283fn ensure_filetags_has_noexport(lines: &mut [String]) {
1284 for line in lines.iter_mut() {
1285 let Some(rest) = strip_file_keyword(line.trim(), "FILETAGS") else {
1286 continue;
1287 };
1288 let tags: Vec<&str> = rest
1289 .trim()
1290 .trim_matches(':')
1291 .split(':')
1292 .map(str::trim)
1293 .filter(|t| !t.is_empty())
1294 .collect();
1295 if tags.iter().any(|t| t.eq_ignore_ascii_case("noexport")) {
1296 return;
1297 }
1298 let mut all = tags;
1299 all.push("noexport");
1300 *line = format!("#+FILETAGS: :{}:", all.join(":"));
1301 return;
1302 }
1303}
1304
1305pub fn settle_heading_classifiers(
1310 org_tags: &mut Vec<String>,
1311 properties: &mut std::collections::BTreeMap<String, String>,
1312) {
1313 fn push_tag(org_tags: &mut Vec<String>, tag: &str) {
1314 if !tag.is_empty()
1315 && tag.chars().all(is_org_tag_char)
1316 && !org_tags.iter().any(|seen| seen == tag)
1317 {
1318 org_tags.push(tag.to_string());
1319 }
1320 }
1321 for key in ["VISSUE_TYPE", "TYPE"] {
1322 if let Some(kind) = properties.get(key) {
1323 push_tag(org_tags, kind.trim());
1324 }
1325 }
1326 if let Some(raw) = properties.get(crate::model::TAGS_PROPERTY).cloned() {
1327 let mut kept = Vec::new();
1328 for tag in raw
1329 .split([',', ':'])
1330 .map(str::trim)
1331 .filter(|t| !t.is_empty())
1332 {
1333 if tag.chars().all(is_org_tag_char) {
1334 push_tag(org_tags, tag);
1335 } else {
1336 kept.push(tag.to_string());
1337 }
1338 }
1339 if kept.is_empty() {
1340 properties.remove(crate::model::TAGS_PROPERTY);
1341 } else {
1342 properties.insert(crate::model::TAGS_PROPERTY.to_string(), kept.join(","));
1343 }
1344 }
1345}
1346
1347pub const COMPUTED_SPECIALS: &[&str] = &[
1350 "ALLTAGS",
1351 "BLOCKED",
1352 "CLOCKSUM",
1353 "CLOCKSUM_T",
1354 "FILE",
1355 "ITEM",
1356 "PRIORITY",
1357 "TAGS",
1358 "TIMESTAMP",
1359 "TIMESTAMP_IA",
1360 "TODO",
1361];
1362
1363pub const SETTABLE_SPECIALS: &[&str] = &[
1366 "ARCHIVE",
1367 "CATEGORY",
1368 "COLUMNS",
1369 "COOKIE_DATA",
1370 "LOGGING",
1371 "ORDERED",
1372 "STYLE",
1373];
1374
1375const EDNA_ATOMS: &[&str] = &[
1377 "ancestors",
1378 "chain-siblings",
1379 "children",
1380 "descendants",
1381 "file-progress",
1382 "first-child",
1383 "has-property",
1384 "heading",
1385 "headings",
1386 "id",
1387 "ids",
1388 "last-child",
1389 "match",
1390 "next-sibling",
1391 "olp",
1392 "parent",
1393 "prev-sibling",
1394 "previous-sibling",
1395 "relatives",
1396 "rest-of-siblings",
1397 "siblings",
1398 "todo-state",
1399 "todo-state!",
1400];
1401
1402pub fn split_id_list(raw: &str) -> Vec<String> {
1404 raw.split(|c: char| c == ',' || c.is_whitespace())
1405 .map(str::trim)
1406 .filter(|x| !x.is_empty())
1407 .map(str::to_string)
1408 .collect()
1409}
1410
1411pub fn is_edna_blocker(raw: &str) -> bool {
1415 let trimmed = raw.trim();
1416 if trimmed.contains('(') {
1417 return true;
1418 }
1419 trimmed.split_whitespace().any(|tok| {
1420 let atom = tok.trim_end_matches('!');
1421 EDNA_ATOMS
1422 .iter()
1423 .any(|known| atom.eq_ignore_ascii_case(known))
1424 })
1425}
1426
1427pub fn edna_blocker_id_refs(raw: &str) -> Vec<&str> {
1429 let mut ids = Vec::new();
1430 let mut rest = raw;
1431 while let Some(start) = rest.find('(') {
1432 let Some(end) = rest[start + 1..].find(')') else {
1433 break;
1434 };
1435 let inner = &rest[start + 1..start + 1 + end];
1436 for id in inner.split(|c: char| c == ',' || c.is_whitespace()) {
1437 let id = id.trim();
1438 if !id.is_empty() && !id.contains('"') && !ids.contains(&id) {
1439 ids.push(id);
1440 }
1441 }
1442 rest = &rest[start + 1 + end + 1..];
1443 }
1444 ids
1445}
1446
1447pub fn edna_blocker_ids(raw: &str) -> Vec<String> {
1449 edna_blocker_id_refs(raw)
1450 .into_iter()
1451 .map(str::to_string)
1452 .collect()
1453}
1454
1455pub fn blocker_ids_from_properties(
1459 properties: &std::collections::BTreeMap<String, String>,
1460) -> Vec<String> {
1461 let mut ids = Vec::new();
1462 for key in ["VISSUE_BLOCKED_BY", "BLOCKED_BY", "BLOCKEDBY"] {
1463 if let Some(raw) = properties.get(key) {
1464 for id in split_id_list(raw) {
1465 if !ids.iter().any(|seen| seen == &id) {
1466 ids.push(id);
1467 }
1468 }
1469 }
1470 }
1471 if let Some(raw) = properties.get("BLOCKER") {
1472 let extra = if is_edna_blocker(raw) {
1473 edna_blocker_ids(raw)
1474 } else {
1475 split_id_list(raw)
1476 };
1477 for id in extra {
1478 if !ids.iter().any(|seen| seen == &id) {
1479 ids.push(id);
1480 }
1481 }
1482 }
1483 ids
1484}
1485
1486pub fn effort_from_properties(
1489 properties: &std::collections::BTreeMap<String, String>,
1490) -> Option<&str> {
1491 properties
1492 .get("Effort")
1493 .or_else(|| properties.get("EFFORT"))
1494 .map(|s| s.trim())
1495 .filter(|s| !s.is_empty())
1496}
1497
1498pub fn is_org_effort(raw: &str) -> bool {
1500 let s = raw.trim();
1501 if s.is_empty() {
1502 return false;
1503 }
1504 if let Some((h, m)) = s.split_once(':') {
1505 return !h.is_empty()
1506 && h.chars().all(|c| c.is_ascii_digit())
1507 && !m.is_empty()
1508 && m.chars().all(|c| c.is_ascii_digit());
1509 }
1510 let (num, unit) = s.split_at(
1511 s.find(|c: char| !c.is_ascii_digit() && c != '.')
1512 .unwrap_or(s.len()),
1513 );
1514 !num.is_empty() && matches!(unit, "h" | "d" | "m" | "w" | "min" | "")
1515}
1516
1517fn strip_file_keyword<'a>(trimmed: &'a str, name: &str) -> Option<&'a str> {
1518 let rest = trimmed.strip_prefix("#+")?;
1519 let (key, value) = rest.split_once(':')?;
1520 if key.eq_ignore_ascii_case(name) {
1521 Some(value)
1522 } else {
1523 None
1524 }
1525}
1526
1527#[derive(Debug, Clone, PartialEq, Eq)]
1529pub struct HeadlineBits<'a> {
1530 pub keyword: Option<&'a str>,
1532 pub priority: Option<char>,
1534 pub commented: bool,
1536 pub rest: &'a str,
1538}
1539
1540pub fn parse_headline_bits<'a>(after_stars: &'a str, keywords: &[String]) -> HeadlineBits<'a> {
1542 let trimmed = after_stars.trim();
1543 let mut rest = trimmed;
1544 let mut keyword = None;
1545 if let Some((word, after)) = first_word(rest)
1546 && is_listed_keyword(word, keywords)
1547 {
1548 keyword = Some(word);
1549 rest = after.trim_start();
1550 }
1551 let mut priority = None;
1552 if let Some((p, after)) = parse_priority_cookie(rest) {
1553 priority = Some(p);
1554 rest = after.trim_start();
1555 }
1556 let mut commented = false;
1557 if let Some((word, after)) = first_word(rest)
1558 && word == "COMMENT"
1559 {
1560 commented = true;
1561 rest = after.trim_start();
1562 }
1563 HeadlineBits {
1564 keyword,
1565 priority,
1566 commented,
1567 rest,
1568 }
1569}
1570
1571fn first_word(s: &str) -> Option<(&str, &str)> {
1572 let s = s.trim_start();
1573 if s.is_empty() {
1574 return None;
1575 }
1576 match s.find(char::is_whitespace) {
1577 Some(i) => Some((&s[..i], &s[i..])),
1578 None => Some((s, "")),
1579 }
1580}
1581
1582fn is_listed_keyword(word: &str, keywords: &[String]) -> bool {
1583 keywords.iter().any(|k| k == word)
1584}
1585
1586pub fn is_issue_headline(line: &str, keywords: &[String]) -> bool {
1588 let Some(after) = line.strip_prefix("* ") else {
1589 return false;
1590 };
1591 let bits = parse_headline_bits(after, keywords);
1592 bits.keyword.is_some() && !bits.commented
1593}
1594
1595pub fn parse_priority_cookie(after: &str) -> Option<(char, &str)> {
1597 let rest = after.strip_prefix("[#")?;
1598 let mut chars = rest.char_indices();
1599 let (_, priority) = chars.next()?;
1600 let (close, bracket) = chars.next()?;
1601 if bracket != ']' {
1602 return None;
1603 }
1604 Some((priority, &rest[close + 1..]))
1605}
1606
1607pub fn split_statistics_cookies(text: &str) -> (String, Option<String>) {
1611 let mut trimmed = text.trim_end().to_string();
1612 let mut cookies = Vec::new();
1613 while let Some(open) = trimmed.rfind('[') {
1614 if !trimmed.ends_with(']') {
1615 break;
1616 }
1617 let cookie = &trimmed[open..];
1618 if !is_statistics_cookie(cookie) {
1619 break;
1620 }
1621 let prefix = trimmed[..open].trim_end();
1622 if open > 0 && !trimmed[..open].ends_with(char::is_whitespace) {
1623 break;
1624 }
1625 cookies.push(cookie.to_string());
1626 trimmed = prefix.to_string();
1627 }
1628 cookies.reverse();
1629 if cookies.is_empty() {
1630 (text.trim_end().to_string(), None)
1631 } else {
1632 (trimmed, Some(cookies.join(" ")))
1633 }
1634}
1635
1636fn is_statistics_cookie(cookie: &str) -> bool {
1637 let Some(inner) = cookie.strip_prefix('[').and_then(|s| s.strip_suffix(']')) else {
1638 return false;
1639 };
1640 if let Some((a, b)) = inner.split_once('/') {
1642 return a.chars().all(|c| c.is_ascii_digit()) && b.chars().all(|c| c.is_ascii_digit());
1643 }
1644 inner
1645 .strip_suffix('%')
1646 .is_some_and(|n| n.chars().all(|c| c.is_ascii_digit()))
1647}
1648
1649pub fn take_timestamp(s: &str) -> Option<(&str, &str)> {
1656 let start = s.trim_start();
1657 let leading = s.len() - start.len();
1658 if start.starts_with("<%%") {
1659 let end = start.find('>')?;
1660 let consumed = leading + end + 1;
1661 return Some((s[leading..consumed].trim_end(), s[consumed..].trim_start()));
1662 }
1663 let close = match start.chars().next()? {
1664 '<' => '>',
1665 '[' => ']',
1666 _ => return None,
1667 };
1668 let end = start.find(close)?;
1669 let mut consumed = leading + end + 1;
1670 let rest = &s[consumed..];
1671 if let Some(after) = rest.strip_prefix("--") {
1672 let after = after.trim_start();
1673 if after.starts_with('<') || after.starts_with('[') || after.starts_with("<%%") {
1674 let close2 = if after.starts_with('[') { ']' } else { '>' };
1675 let end2 = after.find(close2)?;
1676 consumed = s.len() - after.len() + end2 + 1;
1677 }
1678 }
1679 Some((s[leading..consumed].trim_end(), s[consumed..].trim_start()))
1680}
1681
1682pub fn parse_planning_line(line: &str) -> Vec<(String, String)> {
1688 let mut rest = line.trim();
1689 let mut found = Vec::new();
1690 while !rest.is_empty() {
1691 let Some(key) = PLANNING_KEYS
1692 .iter()
1693 .find(|key| rest.starts_with(&format!("{key}:")))
1694 else {
1695 return Vec::new();
1696 };
1697 let after = rest[key.len() + 1..].trim_start();
1698 let Some((ts, next)) = take_timestamp(after) else {
1699 return Vec::new();
1700 };
1701 found.push(((*key).to_string(), ts.to_string()));
1702 rest = next;
1703 }
1704 found
1705}
1706
1707pub fn is_planning_line(trimmed: &str) -> bool {
1709 PLANNING_KEYS
1710 .iter()
1711 .any(|key| trimmed.starts_with(key) && trimmed[key.len()..].starts_with(':'))
1712}
1713
1714pub fn org_link_targets(body: &str, known_ids: &std::collections::HashSet<&str>) -> Vec<String> {
1720 let mut targets = Vec::new();
1721 let mut rest = body;
1722 while let Some(start) = rest.find("[[") {
1723 let after_start = &rest[start + 2..];
1724 let Some(end) = after_start.find("]]") else {
1725 break;
1726 };
1727 let raw = &after_start[..end];
1728 let target = raw.split_once("][").map_or(raw, |(target, _)| target);
1729 push_link_target(&mut targets, target, known_ids);
1730 rest = &after_start[end + 2..];
1731 }
1732 rest = body;
1733 while let Some(start) = rest.find('<') {
1734 let after = &rest[start + 1..];
1735 let Some(end) = after.find('>') else {
1736 break;
1737 };
1738 push_link_target(&mut targets, &after[..end], known_ids);
1739 rest = &after[end + 1..];
1740 }
1741 rest = body;
1742 while let Some(start) = rest.find("id:") {
1743 let after = &rest[start + 3..];
1744 let len = after
1745 .find(|c: char| !c.is_ascii_alphanumeric() && c != '_' && c != '-')
1746 .unwrap_or(after.len());
1747 let id = &after[..len];
1748 if !id.is_empty() && known_ids.contains(id) && !targets.iter().any(|t| t == id) {
1749 targets.push(id.to_string());
1750 }
1751 rest = &after[len.max(1)..];
1752 }
1753 targets
1754}
1755
1756fn push_link_target(
1757 targets: &mut Vec<String>,
1758 raw: &str,
1759 known_ids: &std::collections::HashSet<&str>,
1760) {
1761 let target = raw.trim();
1762 let target = target.strip_prefix("id:").unwrap_or(target);
1763 let target = target.rsplit_once("::").map_or(target, |(_, fragment)| {
1764 fragment.strip_prefix('#').unwrap_or(fragment)
1765 });
1766 let target = target.strip_prefix('#').unwrap_or(target);
1767 if known_ids.contains(target) && !targets.iter().any(|t| t == target) {
1768 targets.push(target.to_string());
1769 }
1770}
1771
1772pub fn property_key_and_append(key: &str) -> (&str, bool) {
1776 match key.strip_suffix('+') {
1777 Some(bare) if !bare.is_empty() => (bare, true),
1778 _ => (key, false),
1779 }
1780}
1781
1782#[cfg(test)]
1783mod tests {
1784 use super::*;
1785
1786 #[test]
1787 fn the_todo_sequence_splits_at_the_bar_or_at_the_last_keyword() {
1788 let seq = todo_sequence_from_lines(&["#+TODO: TODO WAITING(w) | DONE WONTFIX"]);
1789 assert_eq!(seq.open, ["TODO", "STARTED", "BLOCKED", "WAITING"]);
1790 assert_eq!(seq.done, ["DONE", "CANCELLED", "WONTFIX"]);
1791 let bare = todo_sequence_from_lines(&["#+SEQ_TODO: NEXT FINISHED"]);
1792 assert!(bare.open.iter().any(|k| k == "NEXT"));
1793 assert!(bare.is_done("FINISHED"));
1794 assert!(TodoSequence::house().knows("CANCELLED"));
1795 assert!(!TodoSequence::house().knows("WAITING"));
1796 }
1797
1798 #[test]
1799 fn a_repeater_shifts_as_org_shifts_it() {
1800 let today = chrono::NaiveDate::from_ymd_opt(2026, 9, 20).unwrap();
1801 assert_eq!(
1802 shift_repeating_timestamp("<2026-09-22 Tue +1w>", today).as_deref(),
1803 Some("<2026-09-29 Tue +1w>")
1804 );
1805 assert_eq!(
1806 shift_repeating_timestamp("<2026-09-01 Tue 10:00 ++1w -2d>", today).as_deref(),
1807 Some("<2026-09-22 Tue 10:00 ++1w -2d>")
1808 );
1809 assert_eq!(
1810 shift_repeating_timestamp("<2026-01-31 Sat .+1m>", today).as_deref(),
1811 Some("<2026-10-20 Tue .+1m>")
1812 );
1813 assert_eq!(
1814 shift_repeating_timestamp("<2026-01-31 Sat +1m>", today).as_deref(),
1815 Some("<2026-02-28 Sat +1m>")
1816 );
1817 assert!(shift_repeating_timestamp("<2026-09-22 Tue>", today).is_none());
1818 assert!(
1819 shift_repeating_timestamp("<2026-09-22 Tue>--<2026-09-23 Wed +1w>", today).is_none()
1820 );
1821 assert!(has_repeater("<2026-09-22 Tue +1w>"));
1822 assert!(!has_repeater("<2026-09-22 Tue -2d>"));
1823 }
1824
1825 #[test]
1826 fn empty_statistics_cookies_are_cookies() {
1827 assert_eq!(
1828 split_statistics_cookies("Parent of two [/]"),
1829 ("Parent of two".to_string(), Some("[/]".to_string()))
1830 );
1831 assert_eq!(
1832 split_statistics_cookies("Half [%]").1.as_deref(),
1833 Some("[%]")
1834 );
1835 assert_eq!(split_statistics_cookies("Not a cookie [a/b]").1, None);
1836 }
1837 use std::collections::HashSet;
1838
1839 fn house() -> Vec<String> {
1840 TODO_KEYWORDS.iter().map(|s| (*s).to_string()).collect()
1841 }
1842
1843 #[test]
1844 fn a_headline_needs_stars_then_a_space_at_column_zero() {
1845 assert!(is_headline("* TODO a title"));
1846 assert!(is_headline("*** deeper"));
1847 assert!(is_top_level_headline("* TODO a title"));
1848 assert!(!is_top_level_headline("** child"));
1849 assert!(!is_headline("**bold** at the start of a line"));
1850 assert!(!is_headline(" * indented is not a headline"));
1851 assert!(!is_headline("not a headline"));
1852 }
1853
1854 #[test]
1855 fn greater_and_dynamic_blocks_hide_their_contents() {
1856 let mut nest = BlockNest::new();
1857 assert!(!nest.inside());
1858 assert!(nest.observe("#+BEGIN_SRC org"));
1859 assert!(nest.inside());
1860 assert!(nest.observe("* TODO quoted"));
1861 assert!(nest.observe("#+begin_example"));
1862 assert!(nest.observe("* still quoted"));
1863 assert!(nest.observe("#+end_example"));
1864 assert!(nest.inside());
1865 assert!(nest.observe("#+END_SRC"));
1866 assert!(!nest.inside());
1867 assert!(nest.observe(" #+BEGIN: clocktable :scope file"));
1868 assert!(nest.observe("* TODO inside clocktable"));
1869 assert!(nest.observe(" #+END:"));
1870 assert!(!nest.inside());
1871 }
1872
1873 #[test]
1874 fn file_local_todo_keywords_accumulate_and_keep_the_house_set() {
1875 let keys = todo_keywords_from_preamble(
1876 "#+TITLE: x\n#+TODO: TODO(t) WAIT(w@) | DONE(d!)\n#+TODO: HOLD | CANCELLED\n",
1877 );
1878 for expected in [
1879 "TODO",
1880 "STARTED",
1881 "BLOCKED",
1882 "DONE",
1883 "CANCELLED",
1884 "WAIT",
1885 "HOLD",
1886 ] {
1887 assert!(
1888 keys.iter().any(|k| k == expected),
1889 "{keys:?} missing {expected}"
1890 );
1891 }
1892 }
1893
1894 #[test]
1895 fn comment_and_section_headlines_are_not_issues() {
1896 let keys = house();
1897 assert!(is_issue_headline("* TODO Ship it", &keys));
1898 assert!(is_issue_headline("* DONE [#A] Ship it", &keys));
1899 assert!(!is_issue_headline("* COMMENT Archive", &keys));
1900 assert!(!is_issue_headline("* TODO COMMENT hidden", &keys));
1901 assert!(is_issue_headline("* TODO [#A] Comment :bug:", &keys));
1904 assert!(is_issue_headline("* TODO comment on the draft", &keys));
1905 assert!(!is_issue_headline("* Notes", &keys));
1906 assert!(!is_issue_headline("** TODO child", &keys));
1907 }
1908
1909 #[test]
1910 fn a_file_local_keyword_is_an_issue() {
1911 let keys = todo_keywords_from_preamble("#+TODO: TODO WAIT | DONE\n");
1912 assert!(is_issue_headline("* WAIT Parked", &keys));
1913 assert!(!is_issue_headline("* HOLD Parked", &keys));
1914 }
1915
1916 #[test]
1917 fn statistics_cookies_split_off_the_title() {
1918 assert_eq!(
1919 split_statistics_cookies("Break it down [2/5]"),
1920 ("Break it down".into(), Some("[2/5]".into()))
1921 );
1922 assert_eq!(
1923 split_statistics_cookies("Break it down [2/5] [40%]"),
1924 ("Break it down".into(), Some("[2/5] [40%]".into()))
1925 );
1926 assert_eq!(
1927 split_statistics_cookies("Array [2/3] leftover"),
1928 ("Array [2/3] leftover".into(), None)
1929 );
1930 assert_eq!(
1931 split_statistics_cookies("Not a cookie [n/a]"),
1932 ("Not a cookie [n/a]".into(), None)
1933 );
1934 }
1935
1936 #[test]
1937 fn timestamps_include_ranges_repeaters_and_diary_sexps() {
1938 let (ts, rest) = take_timestamp("<2026-09-01 Tue +1w -2d> leftover").unwrap();
1939 assert_eq!(ts, "<2026-09-01 Tue +1w -2d>");
1940 assert_eq!(rest, "leftover");
1941 let (ts, rest) = take_timestamp("<2026-09-01 Tue>--<2026-09-08 Tue>").unwrap();
1942 assert_eq!(ts, "<2026-09-01 Tue>--<2026-09-08 Tue>");
1943 assert!(rest.is_empty());
1944 let (ts, _) = take_timestamp("[2026-09-01 Tue 09:00-17:00]").unwrap();
1945 assert_eq!(ts, "[2026-09-01 Tue 09:00-17:00]");
1946 let (ts, rest) = take_timestamp("<%%(diary-float t 4 2)> next").unwrap();
1947 assert_eq!(ts, "<%%(diary-float t 4 2)>");
1948 assert_eq!(rest, "next");
1949 }
1950
1951 #[test]
1952 fn a_planning_line_keeps_a_range_and_rejects_prose() {
1953 let found = parse_planning_line(
1954 "CLOSED: [2026-08-14 Fri 03:33] SCHEDULED: <2026-09-01 Tue>--<2026-09-08 Tue> DEADLINE: <2026-09-15 Mon +1w>",
1955 );
1956 assert_eq!(found.len(), 3, "{found:?}");
1957 assert_eq!(found[1].1, "<2026-09-01 Tue>--<2026-09-08 Tue>");
1958 assert_eq!(found[2].1, "<2026-09-15 Mon +1w>");
1959 assert!(parse_planning_line("DEADLINE: is discussed in the design note.").is_empty());
1960 }
1961
1962 #[test]
1963 fn org_links_include_brackets_angles_and_bare_ids() {
1964 let known: HashSet<&str> = ["atlas-1a2b", "beacon-5j6k"].into_iter().collect();
1965 let body =
1966 "See [[id:atlas-1a2b][the parser]] and <id:beacon-5j6k> plus id:atlas-1a2b again.";
1967 let found = org_link_targets(body, &known);
1968 assert_eq!(found, vec!["atlas-1a2b", "beacon-5j6k"]);
1969 }
1970
1971 #[test]
1972 fn filetags_parse_the_in_buffer_keyword() {
1973 assert_eq!(
1974 filetags_from_preamble("#+FILETAGS: :issues:parser:\n"),
1975 vec!["issues", "parser"]
1976 );
1977 }
1978
1979 #[test]
1980 fn tag_settings_parse_groups_and_keys() {
1981 let settings = tag_settings_from_preamble(
1982 "#+FILETAGS: :issues:demo:noexport:\n\
1983 #+TAGS: { bug(b) feature(f) task(t) }\n\
1984 #+TAGS: [ area : core cli ]\n\
1985 #+TAGS: docs(d) perf\n\
1986 #+EXCLUDE_TAGS: noexport\n\
1987 #+SELECT_TAGS: export\n",
1988 );
1989 assert_eq!(settings.filetags, vec!["issues", "demo", "noexport"]);
1990 assert_eq!(
1991 settings
1992 .declared
1993 .iter()
1994 .map(|s| (s.name.as_str(), s.key))
1995 .collect::<Vec<_>>(),
1996 vec![
1997 ("bug", Some('b')),
1998 ("feature", Some('f')),
1999 ("task", Some('t')),
2000 ("area", None),
2001 ("core", None),
2002 ("cli", None),
2003 ("docs", Some('d')),
2004 ("perf", None),
2005 ]
2006 );
2007 assert_eq!(
2008 settings.exclusive,
2009 vec![vec![
2010 "bug".to_string(),
2011 "feature".to_string(),
2012 "task".to_string()
2013 ]]
2014 );
2015 assert_eq!(
2016 settings.hierarchies,
2017 vec![("area".to_string(), vec!["core".into(), "cli".into()])]
2018 );
2019 let own = vec!["core".to_string(), "bug".to_string()];
2020 assert!(settings.matches_query(&own, "area"));
2021 assert!(settings.matches_query(&own, "issues"));
2022 assert!(settings.heading_exportable(&own));
2023 assert!(!settings.heading_exportable(&["noexport".into()]));
2024 assert_eq!(
2025 settings.all_tags(&own),
2026 vec!["core", "bug", "issues", "demo", "noexport"]
2027 );
2028 }
2029
2030 #[test]
2031 fn ensure_org_preamble_inserts_category_and_filetags() {
2032 let raw = "#+TITLE: demo issues\n#+TODO: TODO | DONE";
2033 let out = ensure_org_preamble(raw, "demo");
2034 assert!(out.contains("#+VISSUE: 1"), "{out}");
2035 assert!(out.contains("#+CATEGORY: demo"), "{out}");
2036 assert!(out.contains("#+FILETAGS: :issues:demo:noexport:"), "{out}");
2037 assert!(
2038 out.contains("#+TAGS: { bug(b) feature(f) task(t) chore(c) plan(p) }"),
2039 "{out}"
2040 );
2041 assert!(out.contains("#+EXCLUDE_TAGS: noexport"), "{out}");
2042 assert!(out.contains("#+SELECT_TAGS: export"), "{out}");
2043 assert!(out.contains("#+PRIORITIES: A C C"), "{out}");
2044 assert!(out.find("#+TITLE:").unwrap() < out.find("#+CATEGORY:").unwrap());
2045 assert_eq!(ensure_org_preamble(&out, "demo"), out);
2046 let kept = "#+TITLE: demo issues\n#+FILETAGS: :issues:demo:\n#+TODO: TODO | DONE";
2047 let healed = ensure_org_preamble(kept, "demo");
2048 assert!(
2049 healed.contains("#+FILETAGS: :issues:demo:noexport:"),
2050 "existing FILETAGS gain noexport: {healed}"
2051 );
2052 let old = "#+TITLE: demo issues\n#+VISSUE: 0\n#+CATEGORY: demo\n";
2053 let bumped = ensure_org_preamble(old, "demo");
2054 assert!(bumped.contains("#+VISSUE: 1"), "{bumped}");
2055 assert!(!bumped.contains("#+VISSUE: 0"), "{bumped}");
2056 let future = "#+TITLE: demo issues\n#+VISSUE: 99\n#+CATEGORY: demo\n";
2057 let left = ensure_org_preamble(future, "demo");
2058 assert!(left.contains("#+VISSUE: 99"), "{left}");
2059 }
2060
2061 #[test]
2062 fn protocol_from_preamble_reads_the_vissue_keyword() {
2063 assert_eq!(protocol_from_preamble("#+VISSUE: 1\n"), Some(1));
2064 assert_eq!(protocol_from_preamble("#+VISSUE: protocol 2\n"), Some(2));
2065 assert_eq!(protocol_from_preamble("#+VISSUE: protocol=3\n"), Some(3));
2066 assert_eq!(protocol_from_preamble("#+TITLE: x\n"), None);
2067 }
2068
2069 #[test]
2070 fn priorities_from_preamble_reads_highest_lowest_default() {
2071 let spec = priorities_from_preamble("#+PRIORITIES: A D B\n");
2072 assert_eq!(spec.highest, 'A');
2073 assert_eq!(spec.lowest, 'D');
2074 assert_eq!(spec.default, 'B');
2075 assert!(spec.contains('C'));
2076 assert!(!spec.contains('E'));
2077 assert_eq!(priorities_from_preamble("").default, 'C');
2078 }
2079
2080 #[test]
2081 fn gcal_event_ids_are_not_org_ids() {
2082 assert!(is_gcal_event_id("abc123/primary@group.calendar.google.com"));
2083 assert!(is_gcal_event_id("evt/cal"));
2084 assert!(is_gcal_event_id("abc/def/ghi"));
2085 assert!(!is_gcal_event_id("atlas-1a2b"));
2086 assert!(!is_gcal_event_id("no-slash"));
2087 }
2088
2089 #[test]
2090 fn setupfile_merges_local_inbuffer_settings() {
2091 let dir = tempfile::tempdir().unwrap();
2092 let setup = dir.path().join("house.org");
2093 std::fs::write(
2094 &setup,
2095 "#+TODO: TODO HOLD | DONE\n#+PRIORITIES: A D C\n* not a keyword\n",
2096 )
2097 .unwrap();
2098 let preamble = format!(
2099 "#+TITLE: x\n#+SETUPFILE: {}\n#+CATEGORY: x\n",
2100 setup.display()
2101 );
2102 let merged = merge_setupfile_settings(&preamble, Some(dir.path()));
2103 assert!(merged.contains("#+TODO: TODO HOLD | DONE"), "{merged}");
2104 assert!(merged.contains("#+PRIORITIES: A D C"), "{merged}");
2105 assert!(merged.contains("#+CATEGORY: x"), "{merged}");
2106 assert!(!merged.contains("* not a keyword"), "{merged}");
2107 assert_eq!(priorities_from_preamble(&merged).lowest, 'D');
2108 }
2109
2110 #[test]
2111 fn edna_blocker_is_not_an_id_list() {
2112 assert!(is_edna_blocker("prev-sibling"));
2113 assert!(is_edna_blocker("ids(atlas-1a2b atlas-3e4f)"));
2114 assert!(is_edna_blocker("headings(\"Ship it\")"));
2115 assert!(!is_edna_blocker("atlas-1a2b"));
2116 assert!(!is_edna_blocker("atlas-1a2b beacon-5j6k"));
2117 assert_eq!(
2118 edna_blocker_ids("ids(atlas-1a2b atlas-3e4f) next-sibling"),
2119 vec!["atlas-1a2b", "atlas-3e4f"]
2120 );
2121 let mut props = std::collections::BTreeMap::new();
2122 props.insert("BLOCKER".into(), "atlas-1a2b atlas-3e4f".into());
2123 assert_eq!(
2124 blocker_ids_from_properties(&props),
2125 vec!["atlas-1a2b", "atlas-3e4f"]
2126 );
2127 let mut edna = std::collections::BTreeMap::new();
2128 edna.insert("BLOCKER".into(), "prev-sibling".into());
2129 assert!(blocker_ids_from_properties(&edna).is_empty());
2130 }
2131
2132 #[test]
2133 fn effort_accepts_org_durations() {
2134 assert!(is_org_effort("1:30"));
2135 assert!(is_org_effort("2h"));
2136 assert!(is_org_effort("20d"));
2137 assert!(!is_org_effort("soon"));
2138 let mut props = std::collections::BTreeMap::new();
2139 props.insert("Effort".into(), "2h".into());
2140 assert_eq!(effort_from_properties(&props), Some("2h"));
2141 }
2142
2143 #[test]
2144 fn settle_moves_legal_type_and_tags_onto_the_heading() {
2145 let mut tags = Vec::new();
2146 let mut props = std::collections::BTreeMap::new();
2147 props.insert("TYPE".into(), "bug".into());
2148 props.insert("VISSUE_TAGS".into(), "perf,needs-review".into());
2149 settle_heading_classifiers(&mut tags, &mut props);
2150 assert_eq!(tags, vec!["bug", "perf"]);
2151 assert_eq!(
2152 props.get("VISSUE_TAGS").map(String::as_str),
2153 Some("needs-review")
2154 );
2155 assert_eq!(props.get("TYPE").map(String::as_str), Some("bug"));
2156 }
2157
2158 #[test]
2159 fn property_plus_appends() {
2160 assert_eq!(property_key_and_append("BLOCKED_BY+"), ("BLOCKED_BY", true));
2161 assert_eq!(property_key_and_append("ID"), ("ID", false));
2162 }
2163
2164 #[test]
2165 fn results_keywords_match_what_babel_writes() {
2166 assert!(is_results_keyword("#+RESULTS:"));
2167 assert!(is_results_keyword(" #+results:"));
2168 assert!(is_results_keyword("#+RESULTS[deadbeef]:"));
2169 assert!(is_results_keyword(
2170 "#+RESULTS[(2026-08-18 17:50) abcdef]: named"
2171 ));
2172 assert!(is_results_keyword("#+RESULTS: named"));
2173 assert!(!is_results_keyword("#+RESULTANT:"));
2174 assert!(!is_results_keyword("#+TODO: TODO"));
2175 }
2176
2177 #[test]
2178 fn babel_call_and_affiliated_keywords() {
2179 assert!(is_babel_call("#+CALL: plot(x=1) :results output"));
2180 assert!(is_babel_call("#+call: fn[:session]()"));
2181 assert!(!is_babel_call("#+CALLING:"));
2182 assert!(is_affiliated_keyword("#+NAME: plot"));
2183 assert!(is_affiliated_keyword("#+HEADER: :var x=1"));
2184 assert!(is_affiliated_keyword("#+ATTR_HTML: :width 40"));
2185 assert!(is_affiliated_keyword("#+TBLNAME: old"));
2186 assert!(!is_affiliated_keyword("#+TODO: TODO"));
2187 }
2188
2189 #[test]
2190 fn src_begin_splits_lang_switches_and_headers() {
2191 let head = parse_src_begin(" #+BEGIN_SRC python -n -r :results output :var x=1").unwrap();
2192 assert_eq!(head.lang, "python");
2193 assert_eq!(head.switches, "-n -r");
2194 assert_eq!(head.headers, ":results output :var x=1");
2195 assert_eq!(
2196 parse_header_args(head.headers),
2197 vec![
2198 ("results".into(), "output".into()),
2199 ("var".into(), "x=1".into())
2200 ]
2201 );
2202 }
2203
2204 #[test]
2205 fn noweb_and_inline_src_and_calls() {
2206 assert_eq!(
2207 noweb_refs("use <<setup>> and <<setup(n=1)>>"),
2208 vec!["setup", "setup(n=1)"]
2209 );
2210 assert_eq!(
2211 inline_src_spans("see src_python[:results raw]{print(1)} and src_elisp{(+ 1 2)}"),
2212 vec![
2213 ("python", ":results raw", "print(1)"),
2214 ("elisp", "", "(+ 1 2)")
2215 ]
2216 );
2217 assert_eq!(
2218 inline_call_names("then call_plot[:session](x=1) here"),
2219 vec!["plot"]
2220 );
2221 }
2222
2223 #[test]
2224 fn babel_results_hide_headlines_and_drawers() {
2225 let mut scan = OrgScan::new();
2226 assert!(!scan.observe("#+NAME: dump"));
2227 assert!(!scan.observe("prologue"));
2228 assert!(scan.observe("#+BEGIN_SRC python :results raw"));
2229 assert!(scan.observe("print('* TODO dumped')"));
2230 assert!(scan.observe("#+END_SRC"));
2231 assert!(!scan.inside());
2232 assert!(scan.observe("#+RESULTS:"));
2233 assert!(scan.observe("* TODO dumped"));
2234 assert!(scan.observe(":PROPERTIES:"));
2235 assert!(scan.observe(":ID: ghost-9999"));
2236 assert!(scan.observe(":END:"));
2237 assert!(scan.inside());
2238 assert!(!scan.observe("* TODO real"));
2239 assert!(!scan.inside());
2240 }
2241
2242 #[test]
2243 fn babel_results_table_and_fixed_width_and_drawer() {
2244 let mut scan = OrgScan::new();
2245 assert!(scan.observe("#+RESULTS:"));
2246 assert!(scan.observe("| a | b |"));
2247 assert!(scan.observe("|---+---|"));
2248 assert!(scan.observe("| 1 | 2 |"));
2249 assert!(!scan.observe("after the table"));
2250
2251 let mut scan = OrgScan::new();
2252 assert!(scan.observe("#+RESULTS:"));
2253 assert!(scan.observe(": 42"));
2254 assert!(!scan.observe("not fixed width"));
2255
2256 let mut scan = OrgScan::new();
2257 assert!(scan.observe("#+RESULTS:"));
2258 assert!(scan.observe(":RESULTS:"));
2259 assert!(scan.observe("* looks like a headline"));
2260 assert!(scan.observe(":END:"));
2261 assert!(!scan.observe("* TODO real"));
2262 }
2263}