1use crate::{LogMode, Settings};
10
11#[derive(Clone, Copy, Debug, Eq, PartialEq)]
14pub enum LogKind {
15 Time,
16 Note,
17}
18
19#[derive(Clone, Debug, Eq, PartialEq)]
21pub struct TodoKeyword {
22 pub name: String,
23 pub done: bool,
24 pub sequence: usize,
26 pub log_enter: Option<LogKind>,
27 pub log_leave: Option<LogKind>,
28}
29
30#[derive(Clone, Copy, Debug, Eq, PartialEq)]
34pub struct PriorityRange {
35 pub highest: char,
36 pub lowest: char,
37 pub default: char,
38}
39
40impl PriorityRange {
41 pub fn from_settings(settings: &Settings) -> Self {
44 let first = |value: &str, fallback: char| value.chars().next().unwrap_or(fallback);
45 Self {
46 highest: first(&settings.highest_priority, 'A'),
47 lowest: first(&settings.lowest_priority, 'C'),
48 default: first(&settings.default_priority, 'B'),
49 }
50 }
51
52 pub fn contains(&self, priority: &str) -> bool {
54 let mut characters = priority.chars();
55 match (characters.next(), characters.next()) {
56 (Some(value), None) => (self.highest..=self.lowest).contains(&value),
57 _ => false,
58 }
59 }
60
61 pub fn cookies(&self) -> Vec<String> {
63 (self.highest..=self.lowest)
64 .map(|value| value.to_string())
65 .collect()
66 }
67
68 pub fn value(&self, priority: Option<&str>) -> i64 {
71 let cookie = priority
72 .and_then(|value| value.chars().next())
73 .unwrap_or(self.default);
74 1000 * (self.lowest as i64 - cookie as i64)
75 }
76
77 pub fn describe(&self) -> String {
79 format!("{}–{}", self.highest, self.lowest)
80 }
81}
82
83#[derive(Clone, Copy, Debug, Eq, PartialEq)]
85pub struct LogSettings {
86 pub done: Option<LogKind>,
87 pub repeat: Option<LogKind>,
88 pub reschedule: Option<LogKind>,
89 pub redeadline: Option<LogKind>,
90 pub into_drawer: bool,
91}
92
93pub(crate) fn todo_keywords(source: &str, settings: &Settings) -> Vec<TodoKeyword> {
97 let in_file: Vec<&str> = directives(source, &["TODO", "SEQ_TODO", "TYP_TODO"]).collect();
98 let lines = if in_file.is_empty() {
99 settings.todo_keywords.iter().map(String::as_str).collect()
100 } else {
101 in_file
102 };
103 let mut keywords = Vec::new();
104 for (index, line) in lines.iter().enumerate() {
105 let sequence: Vec<&str> = line.split_whitespace().collect();
106 let separator = sequence.iter().position(|word| *word == "|");
107 let last = sequence.len().saturating_sub(1);
108 for (position, raw) in sequence.iter().enumerate() {
109 if *raw == "|" {
110 continue;
111 }
112 let (name, markers) = raw.split_once('(').map_or((*raw, ""), |(name, rest)| {
113 (name, rest.trim_end_matches(')'))
114 });
115 if name.is_empty() {
116 continue;
117 }
118 let (enter, leave) = markers.split_once('/').unwrap_or((markers, ""));
119 keywords.push(TodoKeyword {
120 name: name.to_string(),
121 done: separator.map_or(position == last, |separator| position > separator),
122 sequence: index,
123 log_enter: log_marker(enter),
124 log_leave: log_marker(leave),
125 });
126 }
127 }
128 keywords
129}
130
131fn log_marker(marker: &str) -> Option<LogKind> {
132 if marker.contains('@') {
133 Some(LogKind::Note)
134 } else if marker.contains('!') {
135 Some(LogKind::Time)
136 } else {
137 None
138 }
139}
140
141fn log_value(mode: LogMode) -> Option<LogKind> {
142 match mode {
143 LogMode::Off => None,
144 LogMode::Time => Some(LogKind::Time),
145 LogMode::Note => Some(LogKind::Note),
146 }
147}
148
149pub(super) fn log_settings(source: &str, settings: &Settings) -> LogSettings {
152 let agenda = settings;
153 let mut logging = LogSettings {
154 done: log_value(agenda.log_done),
155 repeat: log_value(agenda.log_repeat),
156 reschedule: log_value(agenda.log_reschedule),
157 redeadline: log_value(agenda.log_redeadline),
158 into_drawer: agenda.log_into_drawer,
159 };
160 for option in directives(source, &["STARTUP"]).flat_map(str::split_whitespace) {
161 let option = option.to_ascii_lowercase();
162 let (kind, name) = if let Some(name) = option.strip_prefix("nolog") {
163 (None, name)
164 } else if let Some(name) = option.strip_prefix("lognote") {
165 (Some(LogKind::Note), name)
166 } else if let Some(name) = option.strip_prefix("log") {
167 (Some(LogKind::Time), name)
168 } else {
169 continue;
170 };
171 match name {
172 "done" => logging.done = kind,
173 "repeat" => logging.repeat = kind,
174 "reschedule" => logging.reschedule = kind,
175 "redeadline" => logging.redeadline = kind,
176 "drawer" => logging.into_drawer = kind.is_some(),
177 _ => {}
178 }
179 }
180 logging
181}
182
183pub(super) fn priority_range(source: &str, settings: &Settings) -> PriorityRange {
186 directive(source, "PRIORITIES")
187 .and_then(priorities_line)
188 .unwrap_or_else(|| PriorityRange::from_settings(settings))
189}
190
191pub(crate) fn ignored_priorities_line(source: &str) -> Option<&str> {
196 directive(source, "PRIORITIES").filter(|value| priorities_line(value).is_none())
197}
198
199fn priorities_line(value: &str) -> Option<PriorityRange> {
202 let letters: Vec<char> = value
203 .split_whitespace()
204 .map(|token| {
205 let mut characters = token.chars();
206 match (characters.next(), characters.next()) {
207 (Some(letter @ 'A'..='Z'), None) => Some(letter),
208 _ => None,
209 }
210 })
211 .collect::<Option<_>>()?;
212 match letters.as_slice() {
213 [highest, lowest, default]
214 if highest <= lowest && (highest..=lowest).contains(&default) =>
215 {
216 Some(PriorityRange {
217 highest: *highest,
218 lowest: *lowest,
219 default: *default,
220 })
221 }
222 _ => None,
223 }
224}
225
226fn directives<'a>(source: &'a str, names: &'a [&str]) -> impl Iterator<Item = &'a str> {
227 source.lines().filter_map(move |line| {
228 let (key, value) = line.strip_prefix("#+")?.split_once(':')?;
229 names
230 .iter()
231 .any(|name| key.eq_ignore_ascii_case(name))
232 .then_some(value.trim())
233 })
234}
235
236pub(super) fn directive<'a>(source: &'a str, name: &str) -> Option<&'a str> {
237 source.lines().find_map(|line| {
238 let (key, value) = line.strip_prefix("#+")?.split_once(':')?;
239 key.eq_ignore_ascii_case(name).then_some(value.trim())
240 })
241}
242
243#[cfg(test)]
244mod tests {
245 use std::path::PathBuf;
246
247 use super::*;
248 use crate::document::OrgDocument;
249
250 #[test]
251 fn in_file_priorities_override_the_configured_range() {
252 let parse = |source: &str| {
253 OrgDocument::parse(
254 PathBuf::from("/tmp/p.org"),
255 source.as_bytes().to_vec(),
256 &Settings::default(),
257 )
258 .unwrap()
259 .priorities()
260 };
261 let in_file = parse("#+PRIORITIES: A E C\n* TODO [#E] Low\n");
262 assert_eq!(
263 in_file,
264 PriorityRange {
265 highest: 'A',
266 lowest: 'E',
267 default: 'C'
268 }
269 );
270 assert!(in_file.contains("E"));
271 assert!(!in_file.contains("F"));
272 assert_eq!(in_file.value(None), in_file.value(Some("C")));
273 assert!(in_file.value(Some("A")) > in_file.value(Some("E")));
274 for source in [
276 "#+PRIORITIES: 1 5 3\n",
277 "#+PRIORITIES: C A B\n",
278 "#+PRIORITIES: A C\n",
279 "#+PRIORITIES: 1 10 5\n",
280 ] {
281 assert_eq!(
282 parse(source),
283 PriorityRange::from_settings(&Settings::default())
284 );
285 assert!(ignored_priorities_line(source).is_some(), "{source}");
286 }
287 assert_eq!(ignored_priorities_line("#+PRIORITIES: A E C\n"), None);
288 }
289}