1use std::collections::BTreeMap;
15
16use schemars::JsonSchema;
17use serde::{Deserialize, Serialize};
18use serde_json::Value;
19
20use crate::{NativeId, SourceError, Task};
21
22#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
24#[serde(deny_unknown_fields)]
25pub struct TaskLink {
26 #[serde(rename = "type")]
29 pub link_type: String,
30 pub direction: LinkDirection,
32 pub other: String,
35}
36
37#[derive(
39 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
40)]
41#[serde(rename_all = "kebab-case")]
42pub enum LinkDirection {
43 Out,
45 In,
47}
48
49impl TaskLink {
50 pub const METADATA_KEY: &'static str = "onetaskgraph.links";
56
57 pub fn held(metadata: &BTreeMap<String, Value>) -> Result<Vec<Self>, String> {
63 match metadata.get(Self::METADATA_KEY) {
64 None => Ok(Vec::new()),
65 Some(value) => serde_json::from_value(value.clone()).map_err(|error| {
66 format!(
67 "its `{}` is not a list of {{\"type\", \"direction\", \"other\"}}: {error}",
68 Self::METADATA_KEY
69 )
70 }),
71 }
72 }
73}
74
75#[derive(Debug, Clone, PartialEq)]
77pub struct Relations {
78 pub task: Task,
81 pub parent: Option<NativeId>,
83 pub children: Vec<NativeId>,
85 pub children_complete: bool,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct ParentWrite {
92 pub child: NativeId,
94 pub parent: Option<NativeId>,
96 pub if_version: Option<String>,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq)]
103pub enum ParentAnswer {
104 Written {
106 version: Option<String>,
108 },
109 Unchanged {
111 version: Option<String>,
113 },
114 VersionMoved {
116 found: Option<String>,
118 },
119 Refused {
122 reason: String,
124 },
125 Missing {
127 what: NativeId,
129 },
130}
131
132#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct NativeLink {
135 pub from: NativeId,
137 pub to: NativeId,
139 pub native: NativeWord,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
146pub struct NativeWord(String);
147
148impl NativeWord {
149 #[must_use]
151 pub fn as_str(&self) -> &str {
152 &self.0
153 }
154}
155
156impl std::fmt::Display for NativeWord {
157 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158 f.write_str(&self.0)
159 }
160}
161
162#[derive(Debug, Clone, Default, PartialEq, Eq)]
165pub struct LinkTypes(BTreeMap<String, NativeWord>);
166
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
169#[serde(deny_unknown_fields)]
170pub struct LinkTypeConfig {
171 #[serde(default)]
173 pub native: Option<String>,
174}
175
176impl LinkTypes {
177 pub fn checked(
186 kind: &str,
187 configured: &BTreeMap<String, LinkTypeConfig>,
188 words: &[&str],
189 reasons: &[(&str, &str)],
190 ) -> Result<Self, SourceError> {
191 let mut mirrored = BTreeMap::new();
192 for (name, entry) in configured {
193 match Self::type_name(name) {
196 Ok(checked) if checked == name => {}
197 Ok(checked) => {
198 return Err(SourceError::Config {
199 message: format!(
200 "link_types names the type {name:?} with surrounding whitespace, which \
201 a link's type never carries; name it {checked:?}"
202 ),
203 });
204 }
205 Err(why) => {
206 return Err(SourceError::Config {
207 message: format!("link_types names the type {name:?}, which {why}"),
208 });
209 }
210 }
211 let Some(word) = &entry.native else {
212 continue;
213 };
214 if !words.contains(&word.as_str()) {
215 let why = reasons
216 .iter()
217 .find(|(refused, _)| refused == word)
218 .map_or_else(String::new, |(_, why)| format!(" — {why}"));
219 let allowed = if words.is_empty() {
220 format!("the {kind} plugin mirrors no link natively; remove `native`")
221 } else {
222 format!(
223 "the {kind} plugin mirrors a link only as {}",
224 words
225 .iter()
226 .map(|word| format!("`{word}`"))
227 .collect::<Vec<_>>()
228 .join(" or ")
229 )
230 };
231 return Err(SourceError::Config {
232 message: format!(
233 "link_types.{name}.native is `{word}`, which this source cannot mirror a \
234 link as{why}; {allowed}"
235 ),
236 });
237 }
238 mirrored.insert(name.clone(), NativeWord(word.clone()));
239 }
240 Ok(Self(mirrored))
241 }
242
243 pub fn type_name(name: &str) -> Result<&str, &'static str> {
252 if name.chars().any(char::is_control) {
255 return Err(
256 "holds a line break or another control character, which its `## Links` section \
257 cannot list on one line",
258 );
259 }
260 let name = name.trim();
261 if name.is_empty() {
262 return Err("names no type");
263 }
264 Ok(name)
265 }
266
267 #[must_use]
269 pub fn native(&self, link_type: &str) -> Option<&NativeWord> {
270 self.0.get(link_type)
271 }
272
273 #[must_use]
275 pub fn mirrored(&self) -> &BTreeMap<String, NativeWord> {
276 &self.0
277 }
278}
279
280#[must_use]
282pub fn relationless(kind: &str) -> SourceError {
283 SourceError::Refused {
284 message: format!("the {kind} plugin holds no relations between tasks"),
285 }
286}
287
288pub const LINKS_HEADING: &str = "## Links";
290
291#[must_use]
299pub fn strip_links_section(content: &str) -> &str {
300 let mut at = None;
301 let mut offset = 0;
302 for line in content.split_inclusive('\n') {
303 if line.trim_end() == LINKS_HEADING {
304 at = Some(offset);
305 }
306 offset += line.len();
307 }
308 let Some(start) = at else {
309 return content;
310 };
311 let after = &content[start..];
312 let only_bullets = after
313 .split_inclusive('\n')
314 .skip(1)
315 .map(str::trim)
316 .all(|line| line.is_empty() || line.starts_with("- ") || line.starts_with("* "));
317 if !only_bullets {
318 return content;
319 }
320 let before = &content[..start];
321 before
322 .strip_suffix("\n\n")
323 .unwrap_or_else(|| before.trim_end_matches('\n'))
324}
325
326#[must_use]
329pub fn with_links_section(content: &str, bullets: &[String]) -> String {
330 if bullets.is_empty() {
331 return content.to_owned();
332 }
333 let mut out = String::with_capacity(content.len() + 64);
334 if !content.is_empty() {
335 out.push_str(content);
336 out.push_str("\n\n");
337 }
338 out.push_str(LINKS_HEADING);
339 out.push_str("\n\n");
340 for bullet in bullets {
341 out.push_str("- ");
342 out.push_str(bullet);
343 out.push('\n');
344 }
345 out
346}
347
348#[must_use]
352pub fn read_content(content: Option<String>, metadata: &BTreeMap<String, Value>) -> Option<String> {
353 if !metadata.contains_key(TaskLink::METADATA_KEY) {
354 return content;
355 }
356 let content = content?;
357 let stripped = strip_links_section(&content);
358 if stripped.len() == content.len() {
359 return Some(content);
360 }
361 if stripped.is_empty() {
362 None
363 } else {
364 Some(stripped.to_owned())
365 }
366}
367
368#[must_use]
371pub fn link_line(link: &TaskLink) -> String {
372 format!(
373 "**{}** {} `{}`",
374 link.link_type,
375 arrow(link.direction),
376 link.other
377 )
378}
379
380#[must_use]
382pub const fn arrow(direction: LinkDirection) -> &'static str {
383 match direction {
384 LinkDirection::Out => "→",
385 LinkDirection::In => "←",
386 }
387}
388
389#[must_use]
399pub fn relinked(content: &str, stored: Option<&str>, metadata: &BTreeMap<String, Value>) -> String {
400 let Ok(links) = TaskLink::held(metadata) else {
401 return content.to_owned();
402 };
403 if links.is_empty() || strip_links_section(content).len() != content.len() {
404 return content.to_owned();
405 }
406 let shown: Vec<&str> = stored
407 .map(|stored| {
408 let kept = strip_links_section(stored).len();
409 stored[kept..]
410 .lines()
411 .map(str::trim)
412 .filter_map(|line| line.strip_prefix("- ").or_else(|| line.strip_prefix("* ")))
413 .collect()
414 })
415 .unwrap_or_default();
416 let bullets: Vec<String> = links
417 .iter()
418 .map(|link| {
419 let typed = format!("**{}** {} ", link.link_type, arrow(link.direction));
420 let named = format!("`{}`", link.other);
421 shown
422 .iter()
423 .find(|line| line.starts_with(&typed) && line.ends_with(&named))
424 .map_or_else(|| link_line(link), |line| (*line).to_owned())
425 })
426 .collect();
427 with_links_section(content, &bullets)
428}
429
430#[cfg(test)]
431mod tests {
432 use super::*;
433
434 fn bullets() -> Vec<String> {
435 vec `s:b`".to_owned()]
436 }
437
438 #[test]
439 fn a_rendered_section_strips_back_to_exactly_the_content_it_was_rendered_onto() {
440 for content in ["", "Body", "Body\n", "Body\n\n", "# Title\n\ntext\n"] {
441 let rendered = with_links_section(content, &bullets());
442 assert_ne!(rendered, content);
443 assert_eq!(strip_links_section(&rendered), content, "{content:?}");
444 }
445 }
446
447 #[test]
448 fn linears_stars_and_extra_blank_lines_are_still_the_section() {
449 let stored = "Body\n\n## Links\n\n\n* **caused-by** → [B](u)\n\n\n* **x** ← [C](v)\n";
450 assert_eq!(strip_links_section(stored), "Body");
451 }
452
453 #[test]
454 fn a_links_heading_a_person_wrote_prose_under_is_left_alone() {
455 let written = "Body\n\n## Links\n\nSee the wiki.\n";
456 assert_eq!(strip_links_section(written), written);
457 assert_eq!(strip_links_section("no section"), "no section");
458 }
459
460 #[test]
461 fn content_of_an_item_holding_no_links_is_never_stripped() {
462 let rendered = with_links_section("Body", &bullets());
463 assert_eq!(
464 read_content(Some(rendered.clone()), &BTreeMap::new()),
465 Some(rendered.clone())
466 );
467 let mut metadata = BTreeMap::new();
468 metadata.insert(TaskLink::METADATA_KEY.to_owned(), Value::Array(Vec::new()));
469 assert_eq!(
470 read_content(Some(rendered), &metadata),
471 Some("Body".to_owned())
472 );
473 assert_eq!(
474 read_content(Some(with_links_section("", &bullets())), &metadata),
475 None
476 );
477 }
478
479 #[test]
480 fn a_native_word_the_plugin_cannot_mirror_as_is_refused_naming_the_type_and_the_word() {
481 let configured = |word: &str| {
482 BTreeMap::from([(
483 "caused-by".to_owned(),
484 LinkTypeConfig {
485 native: Some(word.to_owned()),
486 },
487 )])
488 };
489 assert!(
490 LinkTypes::checked(
491 "github-projects",
492 &configured("relates-to"),
493 &["relates-to"],
494 &[("blocks", "blocking is `depends_on`")]
495 )
496 .is_ok()
497 );
498 for word in ["blocks", "blocked-by", "duplicate", "similar", "frobs"] {
499 let Err(SourceError::Config { message }) =
500 LinkTypes::checked("github-projects", &configured(word), &["relates-to"], &[])
501 else {
502 panic!("{word} was accepted");
503 };
504 assert!(message.contains("caused-by"), "{message}");
505 assert!(message.contains(&format!("`{word}`")), "{message}");
506 }
507 let Err(SourceError::Config { message }) =
508 LinkTypes::checked("local-md", &configured("related"), &[], &[])
509 else {
510 panic!("a native word was accepted on a plugin with none");
511 };
512 assert!(message.contains("mirrors no link natively"), "{message}");
513 }
514
515 #[test]
516 fn links_are_read_from_their_key_and_a_malformed_value_is_refused() {
517 let mut metadata = BTreeMap::new();
518 assert!(TaskLink::held(&metadata).unwrap().is_empty());
519 metadata.insert(
520 TaskLink::METADATA_KEY.to_owned(),
521 serde_json::json!([{"type": "t", "direction": "out", "other": "s:b"}]),
522 );
523 assert_eq!(
524 TaskLink::held(&metadata).unwrap(),
525 vec![TaskLink {
526 link_type: "t".to_owned(),
527 direction: LinkDirection::Out,
528 other: "s:b".to_owned()
529 }]
530 );
531 metadata.insert(TaskLink::METADATA_KEY.to_owned(), serde_json::json!("x"));
532 assert!(TaskLink::held(&metadata).is_err());
533 }
534
535 fn holding(links: serde_json::Value) -> BTreeMap<String, Value> {
536 BTreeMap::from([(TaskLink::METADATA_KEY.to_owned(), links)])
537 }
538
539 #[test]
540 fn a_content_write_re_renders_the_section_keeping_each_stored_line() {
541 let metadata = holding(serde_json::json!([
542 {"type": "caused-by", "direction": "out", "other": "s:b"},
543 {"type": "relates", "direction": "in", "other": "t:c"},
544 ]));
545 let stored = with_links_section("Old", &bullets());
546 let written = relinked("New", Some(&stored), &metadata);
547 assert_eq!(
548 written,
549 with_links_section(
550 "New",
551 &[
552 "**caused-by** → [B](https://example.test/b) `s:b`".to_owned(),
553 "**relates** ← `t:c`".to_owned(),
554 ]
555 )
556 );
557 assert_eq!(strip_links_section(&written), "New");
558 let starred = stored.replace("\n- ", "\n* ");
560 assert_eq!(relinked("New", Some(&starred), &metadata), written);
561 }
562
563 #[test]
564 fn content_carrying_a_section_or_of_an_item_with_no_links_is_written_as_given() {
565 let metadata = holding(serde_json::json!([
566 {"type": "caused-by", "direction": "out", "other": "s:b"}
567 ]));
568 let composed = with_links_section("Body", &bullets());
569 assert_eq!(relinked(&composed, None, &metadata), composed);
570 assert_eq!(relinked("Body", Some(&composed), &BTreeMap::new()), "Body");
571 assert_eq!(
572 relinked("Body", None, &metadata),
573 with_links_section("Body", &["**caused-by** → `s:b`".to_owned()])
574 );
575 }
576}