1use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
4use std::ops::Range;
5
6use pulldown_cmark::{Event, HeadingLevel, LinkType, Options, Parser, Tag, TagEnd};
7
8use crate::frontmatter::{FrontMatter, FrontMatterError};
9use crate::markdown::parent_dir;
10use crate::model::ReasonCode;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct IndexEntry {
15 pub link_text: String,
17 pub dest: String,
21 pub description: String,
23 pub separator: Option<char>,
25 pub text_span: Range<usize>,
27 pub tail_span: Range<usize>,
30 pub line_span: Range<usize>,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum Resolution {
38 External,
40 Unresolvable(&'static str),
43 File(String),
45 Directory(String),
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct Document {
52 pub path: String,
53 pub title: String,
54 pub description: Option<String>,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct IndexMeta {
61 pub path: String,
62 pub title: String,
63 pub description: Option<String>,
64 pub invalid: bool,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
74pub struct IndexIssue {
75 pub reason: ReasonCode,
76 pub target: String,
77 pub detail: String,
78}
79
80#[derive(Debug)]
82pub struct ParsedText {
83 pub text: String,
84 pub front_matter: Result<Option<FrontMatter>, FrontMatterError>,
85}
86
87const BOM: char = '\u{feff}';
88
89fn unescape_brackets(s: &str) -> String {
90 let mut out = String::with_capacity(s.len());
91 let mut chars = s.chars().peekable();
92 while let Some(c) = chars.next() {
93 if c == '\\' && matches!(chars.peek(), Some('\\' | '[' | ']')) {
94 out.push(chars.next().unwrap_or(c));
95 } else {
96 out.push(c);
97 }
98 }
99 out
100}
101
102fn escape_brackets(s: &str) -> String {
103 let mut out = String::with_capacity(s.len());
104 for c in s.chars() {
105 if matches!(c, '\\' | '[' | ']') {
106 out.push('\\');
107 }
108 out.push(c);
109 }
110 out
111}
112
113fn percent_encode(s: &str) -> String {
114 let mut out = String::with_capacity(s.len());
115 for c in s.chars() {
116 match c {
117 ' ' | '(' | ')' | '<' | '>' | '%' | '#' | '\\' => {
118 out.push_str(&format!("%{:02X}", c as u32));
119 }
120 _ => out.push(c),
121 }
122 }
123 out
124}
125
126fn is_inline_event(ev: &Event) -> bool {
127 match ev {
128 Event::Text(_)
129 | Event::Code(_)
130 | Event::InlineMath(_)
131 | Event::DisplayMath(_)
132 | Event::InlineHtml(_)
133 | Event::FootnoteReference(_)
134 | Event::SoftBreak
135 | Event::HardBreak => true,
136 Event::Start(t) => matches!(
137 t,
138 Tag::Emphasis
139 | Tag::Strong
140 | Tag::Strikethrough
141 | Tag::Superscript
142 | Tag::Subscript
143 | Tag::Link { .. }
144 | Tag::Image { .. }
145 ),
146 Event::End(t) => matches!(
147 t,
148 TagEnd::Emphasis
149 | TagEnd::Strong
150 | TagEnd::Strikethrough
151 | TagEnd::Superscript
152 | TagEnd::Subscript
153 | TagEnd::Link
154 | TagEnd::Image
155 ),
156 _ => false,
157 }
158}
159
160pub fn parse_entries(text: &str) -> Vec<IndexEntry> {
172 let (body, off) = match text.strip_prefix(BOM) {
173 Some(b) => (b, BOM.len_utf8()),
174 None => (text, 0),
175 };
176 let events: Vec<(Event, Range<usize>)> = Parser::new_ext(body, Options::ENABLE_TASKLISTS)
177 .into_offset_iter()
178 .collect();
179 let mut entries = Vec::new();
180 for i in 0..events.len() {
181 if !matches!(events[i].0, Event::Start(Tag::Item)) {
182 continue;
183 }
184 let mut k = i + 1;
185 if matches!(events.get(k), Some((Event::Start(Tag::Paragraph), _))) {
186 k += 1;
187 }
188 let (dest_url, link_type, link_range) = match events.get(k) {
189 Some((
190 Event::Start(Tag::Link {
191 dest_url,
192 link_type,
193 ..
194 }),
195 r,
196 )) => (dest_url.to_string(), *link_type, r.clone()),
197 _ => continue,
198 };
199 let mut j = k + 1;
200 let mut t_max = link_range.start + 1;
201 while j < events.len() && !matches!(events[j].0, Event::End(TagEnd::Link)) {
202 t_max = t_max.max(events[j].1.end);
203 j += 1;
204 }
205 let close = body.as_bytes()[t_max.min(body.len())..]
209 .iter()
210 .position(|&b| b == b']')
211 .map_or(t_max, |p| t_max + p);
212 let text_span = link_range.start + 1 + off..close + off;
213 let mut link_end = link_range.end;
215 if link_type == LinkType::Collapsed && body[link_end..].starts_with("[]") {
216 link_end += 2;
217 }
218 let mut m = j + 1;
219 let mut a_max = None::<usize>;
220 while m < events.len() && is_inline_event(&events[m].0) {
221 if !matches!(events[m].0, Event::SoftBreak | Event::HardBreak) {
222 a_max = Some(a_max.map_or(events[m].1.end, |x| x.max(events[m].1.end)));
223 }
224 m += 1;
225 }
226 let tail_end = a_max.map_or(link_end, |x| x.max(link_end));
227 let tail_span = link_end + off..tail_end + off;
228 let raw = &text[tail_span.clone()];
229 let trimmed = raw.trim();
230 let (separator, description) = match trimmed.chars().next() {
231 Some(c) if matches!(c, '-' | '–' | '—' | ':') => {
232 (Some(c), trimmed[c.len_utf8()..].trim_start().to_string())
233 }
234 _ => (None, trimmed.to_string()),
235 };
236 let line_start = text[..text_span.start]
237 .rfind('\n')
238 .map(|p| p + 1)
239 .unwrap_or(0)
240 .max(off);
241 let line_end = match text[tail_span.end..].find('\n') {
242 Some(p) => tail_span.end + p + 1,
243 None => text.len(),
244 };
245 entries.push(IndexEntry {
246 link_text: text[text_span.clone()].to_string(),
247 dest: dest_url,
248 description,
249 separator,
250 text_span,
251 tail_span,
252 line_span: line_start..line_end,
253 });
254 }
255 entries
256}
257
258pub fn resolve(index_path: &str, root: &str, dest: &str, repo: &RepoFiles) -> Resolution {
271 if dest.is_empty() || dest.starts_with('#') {
272 return Resolution::Unresolvable("empty target");
273 }
274 if dest.contains("://") || crate::markdown::has_uri_scheme(dest) {
275 return Resolution::External;
276 }
277 if dest.starts_with('/') {
278 return Resolution::Unresolvable("absolute path");
279 }
280 let no_frag = dest.split('#').next().unwrap_or("");
281 let decoded = crate::markdown::percent_decode(no_frag);
282 let path = match crate::markdown::normalize_relative(index_path, &decoded) {
283 Some(p) => p,
284 None => return Resolution::Unresolvable("escapes repository"),
285 };
286 if !crate::config::is_under(&path, root) {
287 return Resolution::Unresolvable("outside index root");
288 }
289 let own_dir = parent_dir(index_path);
290 let dir_candidate = |dir: String| {
291 if own_dir == dir || own_dir.starts_with(&format!("{dir}/")) {
292 Resolution::Unresolvable("self or ancestor directory")
293 } else {
294 Resolution::Directory(dir)
295 }
296 };
297 let file_name = path.rsplit('/').next().unwrap_or("");
298 if decoded.ends_with('/') {
299 return dir_candidate(path);
300 }
301 if file_name == "index.md" {
302 return dir_candidate(parent_dir(&path).to_string());
303 }
304 if repo.files.contains(path.as_str()) {
305 return Resolution::File(path);
306 }
307 if repo.dirs.contains(path.as_str()) {
308 return dir_candidate(path);
309 }
310 Resolution::File(path)
311}
312
313pub struct RepoFiles<'a> {
315 pub files: HashSet<&'a str>,
316 pub dirs: HashSet<String>,
317}
318
319impl<'a> RepoFiles<'a> {
320 pub fn new(paths: impl IntoIterator<Item = &'a str>) -> Self {
321 let files: HashSet<&str> = paths.into_iter().collect();
322 let mut dirs = HashSet::new();
323 for f in &files {
324 let mut cur = parent_dir(f);
325 while !cur.is_empty() && dirs.insert(cur.to_string()) {
326 cur = parent_dir(cur);
327 }
328 }
329 RepoFiles { files, dirs }
330 }
331}
332
333pub struct IndexTree {
335 root: String,
336 indexes: BTreeSet<String>,
337}
338
339impl IndexTree {
340 pub fn new(root: &str, indexes: impl IntoIterator<Item = String>) -> Self {
341 IndexTree {
342 root: root.to_string(),
343 indexes: indexes.into_iter().collect(),
344 }
345 }
346
347 fn root_index(&self) -> String {
348 format!("{}/index.md", self.root)
349 }
350
351 fn nearest(&self, dir: &str) -> String {
354 let mut cur = dir;
355 loop {
356 let cand = format!("{cur}/index.md");
357 if self.indexes.contains(&cand) {
358 return cand;
359 }
360 if cur == self.root || cur.is_empty() || !crate::config::is_under(cur, &self.root) {
361 return self.root_index();
362 }
363 cur = parent_dir(cur);
364 }
365 }
366
367 pub fn owner_of(&self, doc_path: &str) -> String {
369 self.nearest(parent_dir(doc_path))
370 }
371
372 pub fn parent_of(&self, index_path: &str) -> Option<String> {
374 let dir = parent_dir(index_path);
375 if dir == self.root {
376 None
377 } else {
378 Some(self.nearest(parent_dir(dir)))
379 }
380 }
381
382 pub fn children_of(&self, index_path: &str) -> Vec<String> {
384 self.indexes
385 .iter()
386 .filter(|c| self.parent_of(c).as_deref() == Some(index_path))
387 .cloned()
388 .collect()
389 }
390}
391
392#[derive(Debug, Clone, Copy, PartialEq, Eq)]
394pub enum IndexStatus {
395 Created,
396 Updated,
397 Unchanged,
398}
399
400impl IndexStatus {
401 pub fn as_str(self) -> &'static str {
402 match self {
403 IndexStatus::Created => "created",
404 IndexStatus::Updated => "updated",
405 IndexStatus::Unchanged => "unchanged",
406 }
407 }
408}
409
410#[derive(Debug, Default)]
412pub struct RootFiles {
413 pub documents: Vec<Document>,
414 pub untyped: Vec<String>,
415 pub indexes: Vec<IndexMeta>,
416}
417
418pub fn collect_documents(root: &str, texts: &BTreeMap<String, ParsedText>) -> RootFiles {
425 let mut out = RootFiles::default();
426 for (path, parsed) in texts {
427 if !path.ends_with(".md") || !crate::config::is_under(path, root) {
428 continue;
429 }
430 match path.rsplit('/').next() {
431 Some("index.md") => {
432 let invalid = parsed.front_matter.is_err();
433 let (fm_title, description) = match &parsed.front_matter {
434 Ok(Some(fm)) => (front_title(fm), normalize_description(fm)),
435 _ => (None, None),
436 };
437 let title = fm_title.unwrap_or_else(|| {
438 parent_dir(path)
439 .rsplit('/')
440 .next()
441 .unwrap_or_default()
442 .to_string()
443 });
444 out.indexes.push(IndexMeta {
445 path: path.clone(),
446 title,
447 description,
448 invalid,
449 });
450 continue;
451 }
452 Some("log.md") => continue,
453 _ => {}
454 }
455 match &parsed.front_matter {
456 Err(_) => {}
457 Ok(Some(fm)) if fm.kind.is_some() => {
458 let description = normalize_description(fm);
459 out.documents.push(Document {
460 path: path.clone(),
461 title: document_title(path, fm),
462 description,
463 });
464 }
465 Ok(_) => out.untyped.push(path.clone()),
466 }
467 }
468 out
469}
470
471fn child_index_of(dir: &str) -> String {
472 format!("{dir}/index.md")
473}
474
475fn front_title(fm: &FrontMatter) -> Option<String> {
477 fm.title
478 .as_deref()
479 .map(str::trim)
480 .filter(|t| !t.is_empty() && !t.contains('\n') && !t.contains('\r'))
481 .map(str::to_string)
482}
483
484pub fn normalize_description(fm: &FrontMatter) -> Option<String> {
486 fm.description
487 .as_deref()
488 .map(str::trim)
489 .filter(|d| !d.is_empty() && !d.contains('\n') && !d.contains('\r'))
490 .map(str::to_string)
491}
492
493pub fn document_title(path: &str, fm: &FrontMatter) -> String {
497 if let Some(t) = front_title(fm) {
498 return t;
499 }
500 let body = fm.body.as_str();
501 let mut in_h1 = false;
502 let (mut min, mut max) = (usize::MAX, 0usize);
503 for (ev, range) in Parser::new(body).into_offset_iter() {
504 match ev {
505 Event::Start(Tag::Heading {
506 level: HeadingLevel::H1,
507 ..
508 }) => {
509 in_h1 = true;
510 min = usize::MAX;
511 max = 0;
512 }
513 Event::End(TagEnd::Heading(_)) if in_h1 => {
514 if min != usize::MAX {
517 return body[min..max].to_string();
518 }
519 break;
520 }
521 _ if in_h1 => {
522 min = min.min(range.start);
523 max = max.max(range.end);
524 }
525 _ => {}
526 }
527 }
528 let name = path.rsplit('/').next().unwrap_or(path);
529 name.strip_suffix(".md").unwrap_or(name).to_string()
530}
531
532fn is_excluded(path: &str, config: &crate::config::Config) -> bool {
533 crate::source::is_under_dir_named(path, &config.exclude)
534 || crate::source::is_under_dir_named(path, &crate::source::DEFAULT_EXCLUDED_DIRS)
535}
536
537fn entry_target(index: &str, dest: &str) -> String {
539 format!("{index} -> {dest}")
540}
541
542fn issue(reason: ReasonCode, target: impl Into<String>, detail: impl Into<String>) -> IndexIssue {
543 IndexIssue {
544 reason,
545 target: target.into(),
546 detail: detail.into(),
547 }
548}
549
550pub fn collect_issues(
565 config: &crate::config::Config,
566 files: &[String],
567 texts: &BTreeMap<String, ParsedText>,
568) -> BTreeSet<IndexIssue> {
569 let mut out = BTreeSet::new();
570 let repo = RepoFiles::new(files.iter().map(String::as_str));
571 for raw in &config.index {
572 let root = crate::config::normalize_dir(raw);
573 check_root(config, &root, &repo, texts, &mut out);
574 }
575 out
576}
577
578fn check_root(
579 config: &crate::config::Config,
580 root: &str,
581 repo: &RepoFiles,
582 texts: &BTreeMap<String, ParsedText>,
583 out: &mut BTreeSet<IndexIssue>,
584) {
585 let RootFiles {
586 documents,
587 untyped,
588 indexes,
589 } = collect_documents(root, texts);
590 for u in &untyped {
591 out.insert(issue(
592 ReasonCode::UntypedFile,
593 u.clone(),
594 "file has no `type` in its front matter",
595 ));
596 }
597 for d in &documents {
598 if d.description.is_none() {
599 out.insert(issue(
600 ReasonCode::MissingDescription,
601 d.path.clone(),
602 "document has no single-line `description`",
603 ));
604 }
605 }
606 let root_index = crate::config::join_under(root, "index.md");
607 let index_set: HashSet<&str> = indexes.iter().map(|m| m.path.as_str()).collect();
608 if !index_set.contains(root_index.as_str()) {
609 if !documents.is_empty() {
610 out.insert(issue(
611 ReasonCode::MissingIndex,
612 root.to_string(),
613 format!("{root_index} does not exist"),
614 ));
615 }
616 return;
617 }
618 let doc_map: HashMap<&str, &Document> =
619 documents.iter().map(|d| (d.path.as_str(), d)).collect();
620 let untyped_set: HashSet<&str> = untyped.iter().map(String::as_str).collect();
621 let meta_map: HashMap<&str, &IndexMeta> =
622 indexes.iter().map(|m| (m.path.as_str(), m)).collect();
623 let tree = IndexTree::new(root, indexes.iter().map(|m| m.path.clone()));
624 let mut listed: HashMap<&str, HashSet<String>> = HashMap::new();
625 let mut linked: HashSet<String> = HashSet::new();
626 for index in indexes.iter().map(|m| &m.path) {
627 let text = &texts[index].text;
628 for e in parse_entries(text) {
629 match resolve(index, root, &e.dest, repo) {
630 Resolution::External => {}
631 Resolution::Unresolvable(why) => {
632 out.insert(issue(
633 ReasonCode::BrokenEntry,
634 entry_target(index, &e.dest),
635 why,
636 ));
637 }
638 Resolution::File(p) => match doc_map.get(p.as_str()) {
639 Some(doc) => {
640 listed.entry(index).or_default().insert(p.clone());
641 if unescape_brackets(&e.link_text) != doc.title {
642 out.insert(issue(
643 ReasonCode::TitleMismatch,
644 p.clone(),
645 format!(
646 "link text differs from the document title `{}`",
647 doc.title
648 ),
649 ));
650 }
651 if let Some(d) = &doc.description {
652 if unescape_brackets(&e.description) != *d {
653 out.insert(issue(
654 ReasonCode::DescriptionMismatch,
655 p.clone(),
656 format!("summary differs from the document description `{d}`"),
657 ));
658 }
659 }
660 }
661 None => {
662 let why = if is_excluded(&p, config) {
663 "target is excluded from scan"
664 } else if !repo.files.contains(p.as_str()) {
665 "target does not exist"
666 } else if untyped_set.contains(p.as_str()) {
667 "target has no `type`"
668 } else {
669 "target is not a document"
670 };
671 out.insert(issue(
672 ReasonCode::BrokenEntry,
673 entry_target(index, &e.dest),
674 why,
675 ));
676 }
677 },
678 Resolution::Directory(d) => {
679 let child = child_index_of(&d);
680 if !index_set.contains(child.as_str()) {
681 out.insert(issue(
682 ReasonCode::BrokenEntry,
683 entry_target(index, &e.dest),
684 "directory has no index.md",
685 ));
686 } else {
687 if let Some(m) = meta_map.get(child.as_str()).filter(|m| !m.invalid) {
688 let target = entry_target(index, &e.dest);
689 if unescape_brackets(&e.link_text) != m.title {
690 out.insert(issue(
691 ReasonCode::TitleMismatch,
692 target.clone(),
693 format!(
694 "link text differs from the directory title `{}`",
695 m.title
696 ),
697 ));
698 }
699 match &m.description {
700 Some(d) => {
701 if unescape_brackets(&e.description) != *d {
702 out.insert(issue(
703 ReasonCode::DescriptionMismatch,
704 target,
705 format!(
706 "summary differs from the directory description `{d}`"
707 ),
708 ));
709 }
710 }
711 None => {
712 out.insert(issue(
713 ReasonCode::MissingDirectoryDescription,
714 child.clone(),
715 "directory index has no single-line `description`",
716 ));
717 }
718 }
719 }
720 if tree.parent_of(&child).as_deref() == Some(index.as_str()) {
721 linked.insert(child);
722 }
723 }
724 }
725 }
726 }
727 }
728 for d in &documents {
729 let owner = tree.owner_of(&d.path);
730 let is_listed = listed
731 .get(owner.as_str())
732 .is_some_and(|s| s.contains(&d.path));
733 if !is_listed {
734 out.insert(issue(
735 ReasonCode::NotListed,
736 d.path.clone(),
737 format!("not listed in {owner}"),
738 ));
739 }
740 }
741 for index in indexes.iter().map(|m| &m.path) {
742 if *index != root_index && !linked.contains(index) {
743 out.insert(issue(
744 ReasonCode::UnlinkedIndex,
745 index.clone(),
746 "not linked from its parent index",
747 ));
748 }
749 }
750}
751
752fn is_list_item_line(line: &str) -> bool {
753 let l = line.trim_start();
754 if l.starts_with("- ") || l.starts_with("* ") || l.starts_with("+ ") {
755 return true;
756 }
757 let digits = l.chars().take_while(|c| c.is_ascii_digit()).count();
758 digits > 0 && {
759 let rest = &l[digits..];
760 rest.starts_with(". ") || rest.starts_with(") ")
761 }
762}
763
764#[allow(clippy::too_many_arguments)]
782pub fn update_index(
783 text: &str,
784 index_path: &str,
785 tree: &IndexTree,
786 documents: &[Document],
787 indexes: &[IndexMeta],
788 repo: &RepoFiles,
789 unlinked_child_indexes: &[String],
790 excluded: &[String],
791) -> String {
792 let root = tree.root.as_str();
793 let index_dir = parent_dir(index_path);
794 let doc_map: HashMap<&str, &Document> =
795 documents.iter().map(|d| (d.path.as_str(), d)).collect();
796 let meta_map: HashMap<&str, &IndexMeta> =
797 indexes.iter().map(|m| (m.path.as_str(), m)).collect();
798 let mut edits: Vec<(Range<usize>, String)> = Vec::new();
799 let mut listed: HashSet<String> = HashSet::new();
800 for e in parse_entries(text) {
801 let (doc_path, title, description): (Option<String>, &str, &Option<String>) =
802 match resolve(index_path, root, &e.dest, repo) {
803 Resolution::File(p) => match doc_map.get(p.as_str()) {
804 Some(doc) => (Some(p), doc.title.as_str(), &doc.description),
805 None => {
806 if !repo.files.contains(p.as_str())
807 && !crate::source::is_under_dir_named(&p, excluded)
808 {
809 edits.push((e.line_span.clone(), String::new()));
810 }
811 continue;
812 }
813 },
814 Resolution::Directory(d) => match meta_map.get(child_index_of(&d).as_str()) {
815 Some(m) if !m.invalid => (None, m.title.as_str(), &m.description),
816 _ => continue,
817 },
818 _ => continue,
819 };
820 if let Some(p) = doc_path {
821 listed.insert(p);
822 }
823 let after = &text.as_bytes()[e.text_span.end..];
824 let keep_label = after.first() == Some(&b']')
827 && match after.get(1) {
828 Some(b'(') => false,
829 Some(b'[') => after.get(2) == Some(&b']'),
830 _ => true,
831 };
832 if !keep_label {
833 edits.push((e.text_span.clone(), escape_brackets(title)));
834 }
835 let tail = match description {
836 Some(d) => format!(" {} {}", e.separator.unwrap_or('—'), escape_brackets(d)),
837 None => String::new(),
838 };
839 edits.push((e.tail_span.clone(), tail));
840 }
841 edits.sort_by_key(|e| e.0.start);
842 let mut out = String::with_capacity(text.len());
843 let mut pos = 0;
844 for (range, rep) in edits {
845 out.push_str(&text[pos..range.start]);
846 out.push_str(&rep);
847 pos = range.end;
848 }
849 out.push_str(&text[pos..]);
850
851 let rel = |path: &str| -> String {
852 path.strip_prefix(&format!("{index_dir}/"))
853 .unwrap_or(path)
854 .to_string()
855 };
856 let mut missing: Vec<&Document> = documents
857 .iter()
858 .filter(|d| tree.owner_of(&d.path) == index_path && !listed.contains(&d.path))
859 .collect();
860 missing.sort_by(|a, b| a.path.cmp(&b.path));
861 let mut children: Vec<&String> = unlinked_child_indexes.iter().collect();
862 children.sort();
863 let mut lines: Vec<String> = Vec::new();
864 for d in missing {
865 let mut l = format!(
866 "- [{}]({})",
867 escape_brackets(&d.title),
868 percent_encode(&rel(&d.path))
869 );
870 if let Some(desc) = &d.description {
871 l.push_str(&format!(" — {}", escape_brackets(desc)));
872 }
873 lines.push(l);
874 }
875 for c in children {
876 if meta_map.get(c.as_str()).is_some_and(|m| m.invalid) {
877 continue;
878 }
879 let dir = rel(parent_dir(c));
880 let (title, description) = match meta_map.get(c.as_str()) {
881 Some(m) => (m.title.clone(), m.description.clone()),
882 None => (dir.rsplit('/').next().unwrap_or(&dir).to_string(), None),
883 };
884 let mut l = format!("- [{}]({}/)", escape_brackets(&title), percent_encode(&dir));
885 if let Some(desc) = &description {
886 l.push_str(&format!(" — {}", escape_brackets(desc)));
887 }
888 lines.push(l);
889 }
890 if lines.is_empty() {
891 return out;
892 }
893 let nl = if text.contains("\r\n") { "\r\n" } else { "\n" };
894 if !out.is_empty() {
895 if !out.ends_with('\n') {
896 out.push_str(nl);
897 }
898 if let Some(last) = out.lines().rev().find(|l| !l.trim().is_empty()) {
899 if !is_list_item_line(last) {
900 out.push_str(nl);
901 }
902 }
903 }
904 for l in lines {
905 out.push_str(&l);
906 out.push_str(nl);
907 }
908 out
909}
910
911fn write_atomic(path: &std::path::Path, content: &str) -> anyhow::Result<()> {
912 use std::io::Write;
913 use std::sync::atomic::{AtomicU64, Ordering};
914 static SEQ: AtomicU64 = AtomicU64::new(0);
915 let nanos = std::time::SystemTime::now()
916 .duration_since(std::time::UNIX_EPOCH)
917 .map(|d| d.as_nanos())
918 .unwrap_or(0);
919 let name = format!(
920 ".index.md.{}.{nanos}.{}.tmp",
921 std::process::id(),
922 SEQ.fetch_add(1, Ordering::Relaxed)
923 );
924 let tmp = path.with_file_name(name);
925 let mut f = std::fs::OpenOptions::new()
926 .write(true)
927 .create_new(true)
928 .open(&tmp)?;
929 let tmp_ref = &tmp;
930 let result = (move || -> std::io::Result<()> {
931 f.write_all(content.as_bytes())?;
932 f.sync_all()?;
933 drop(f);
934 std::fs::rename(tmp_ref, path)
935 })();
936 if let Err(e) = result {
937 let _ = std::fs::remove_file(&tmp);
938 return Err(e.into());
939 }
940 Ok(())
941}
942
943pub fn run(
951 repo_root: &std::path::Path,
952 config: &crate::config::Config,
953) -> anyhow::Result<Vec<(IndexStatus, String)>> {
954 use crate::source::Source;
955 if config.index.is_empty() {
956 anyhow::bail!("index is not configured in .miwa/config.yaml");
957 }
958 let source = crate::source::FsSource::new(repo_root);
959 let files: Vec<String> = source
960 .list_files()?
961 .into_iter()
962 .filter(|p| !crate::source::is_under_dir_named(p, &config.exclude))
963 .collect();
964 let repo = RepoFiles::new(files.iter().map(String::as_str));
965 let roots: Vec<String> = config
966 .index
967 .iter()
968 .map(|r| crate::config::normalize_dir(r))
969 .collect();
970 let mut texts: BTreeMap<String, ParsedText> = BTreeMap::new();
971 for path in &files {
972 if path.ends_with(".md") && roots.iter().any(|r| crate::config::is_under(path, r)) {
973 let bytes = source.read(path)?;
974 let text = if path.rsplit('/').next() == Some("index.md") {
975 String::from_utf8(bytes).map_err(|_| {
976 anyhow::anyhow!("{path} is not valid UTF-8; refusing to rewrite it")
977 })?
978 } else {
979 String::from_utf8_lossy(&bytes).into_owned()
980 };
981 let front_matter = crate::frontmatter::parse(&text);
982 texts.insert(path.clone(), ParsedText { text, front_matter });
983 }
984 }
985 let excluded: Vec<String> = config
986 .exclude
987 .iter()
988 .cloned()
989 .chain(
990 crate::source::DEFAULT_EXCLUDED_DIRS
991 .iter()
992 .map(|s| s.to_string()),
993 )
994 .collect();
995 let mut results = Vec::new();
996 for root in &roots {
997 let RootFiles {
998 documents, indexes, ..
999 } = collect_documents(root, &texts);
1000 let index_paths: Vec<String> = indexes.iter().map(|m| m.path.clone()).collect();
1001 let tree = IndexTree::new(root, index_paths.iter().cloned());
1002 let root_index = crate::config::join_under(root, "index.md");
1003 let targets: Vec<String> = if index_paths.contains(&root_index) {
1004 index_paths.clone()
1005 } else if documents.is_empty() {
1006 continue;
1007 } else {
1008 let mut all = vec![root_index.clone()];
1009 all.extend(index_paths.iter().cloned());
1010 all
1011 };
1012 for index in targets {
1013 let existing = texts.get(&index).map(|t| t.text.as_str());
1014 let text = existing.unwrap_or("");
1015 let linked: HashSet<String> = parse_entries(text)
1016 .into_iter()
1017 .filter_map(|e| match resolve(&index, root, &e.dest, &repo) {
1018 Resolution::Directory(d) => Some(child_index_of(&d)),
1019 _ => None,
1020 })
1021 .collect();
1022 let unlinked: Vec<String> = tree
1023 .children_of(&index)
1024 .into_iter()
1025 .filter(|c| !linked.contains(c))
1026 .collect();
1027 let new_text = update_index(
1028 text, &index, &tree, &documents, &indexes, &repo, &unlinked, &excluded,
1029 );
1030 let status = if existing.is_none() {
1031 IndexStatus::Created
1032 } else if new_text == text {
1033 IndexStatus::Unchanged
1034 } else {
1035 IndexStatus::Updated
1036 };
1037 if status != IndexStatus::Unchanged {
1038 write_atomic(&repo_root.join(&index), &new_text)?;
1039 }
1040 results.push((status, index));
1041 }
1042 }
1043 Ok(results)
1044}
1045
1046#[cfg(test)]
1047mod tests {
1048 use super::*;
1049 use crate::config::Config;
1050
1051 fn dests(text: &str) -> Vec<String> {
1054 parse_entries(text).into_iter().map(|e| e.dest).collect()
1055 }
1056
1057 fn doc(path: &str, title: &str, description: Option<&str>) -> Document {
1058 Document {
1059 path: path.to_string(),
1060 title: title.to_string(),
1061 description: description.map(str::to_string),
1062 }
1063 }
1064
1065 fn typed(title: &str, description: Option<&str>) -> String {
1067 match description {
1068 Some(d) => format!("---\ntype: BusinessNeed\ntitle: {title}\ndescription: {d}\n---\n"),
1069 None => format!("---\ntype: BusinessNeed\ntitle: {title}\n---\n"),
1070 }
1071 }
1072
1073 fn texts_of(files: &[(&str, &str)]) -> BTreeMap<String, ParsedText> {
1074 files
1075 .iter()
1076 .filter(|(p, _)| p.ends_with(".md"))
1077 .map(|(p, t)| {
1078 (
1079 p.to_string(),
1080 ParsedText {
1081 text: t.to_string(),
1082 front_matter: crate::frontmatter::parse(t),
1083 },
1084 )
1085 })
1086 .collect()
1087 }
1088
1089 fn issues_with(
1090 roots: &[&str],
1091 exclude: &[&str],
1092 files: &[(&str, &str)],
1093 ) -> BTreeSet<IndexIssue> {
1094 let mut config = Config::default_config();
1095 config.index = roots.iter().map(|s| s.to_string()).collect();
1096 config.exclude = exclude.iter().map(|s| s.to_string()).collect();
1097 let names: Vec<String> = files.iter().map(|(p, _)| p.to_string()).collect();
1098 collect_issues(&config, &names, &texts_of(files))
1099 }
1100
1101 fn issues(files: &[(&str, &str)]) -> BTreeSet<IndexIssue> {
1102 issues_with(&["docs"], &[], files)
1103 }
1104
1105 fn has(set: &BTreeSet<IndexIssue>, reason: ReasonCode, target: &str) -> bool {
1106 set.iter().any(|i| i.reason == reason && i.target == target)
1107 }
1108
1109 fn count(set: &BTreeSet<IndexIssue>, reason: ReasonCode, target: &str) -> usize {
1110 set.iter()
1111 .filter(|i| i.reason == reason && i.target == target)
1112 .count()
1113 }
1114
1115 fn detail_of(set: &BTreeSet<IndexIssue>, reason: ReasonCode, target: &str) -> String {
1116 set.iter()
1117 .find(|i| i.reason == reason && i.target == target)
1118 .unwrap_or_else(|| panic!("no {reason:?} for {target:?} in {set:?}"))
1119 .detail
1120 .clone()
1121 }
1122
1123 fn fm(text: &str) -> FrontMatter {
1124 crate::frontmatter::parse(text).unwrap().unwrap()
1125 }
1126
1127 fn upd(text: &str, documents: &[Document], files: &[&str], unlinked: &[&str]) -> String {
1130 upd_meta(text, documents, &[], files, unlinked)
1131 }
1132
1133 fn meta(path: &str, title: &str, description: Option<&str>) -> IndexMeta {
1134 IndexMeta {
1135 path: path.to_string(),
1136 title: title.to_string(),
1137 description: description.map(str::to_string),
1138 invalid: false,
1139 }
1140 }
1141
1142 fn index_with(title: Option<&str>, description: Option<&str>) -> String {
1145 let mut s = String::from("---\n");
1146 if let Some(t) = title {
1147 s.push_str(&format!("title: {t}\n"));
1148 }
1149 if let Some(d) = description {
1150 s.push_str(&format!("description: {d}\n"));
1151 }
1152 s.push_str("---\n- [B](b.md) — DB\n");
1153 s
1154 }
1155
1156 fn upd_meta(
1158 text: &str,
1159 documents: &[Document],
1160 indexes: &[IndexMeta],
1161 files: &[&str],
1162 unlinked: &[&str],
1163 ) -> String {
1164 let unlinked: Vec<String> = unlinked.iter().map(|s| s.to_string()).collect();
1165 update_index(
1166 text,
1167 "docs/index.md",
1168 &tree_of(files),
1169 documents,
1170 indexes,
1171 &RepoFiles::new(files.iter().copied()),
1172 &unlinked,
1173 &test_excluded(),
1174 )
1175 }
1176
1177 fn test_excluded() -> Vec<String> {
1178 crate::source::DEFAULT_EXCLUDED_DIRS
1179 .iter()
1180 .map(|s| s.to_string())
1181 .collect()
1182 }
1183
1184 fn tree_of(files: &[&str]) -> IndexTree {
1186 IndexTree::new(
1187 "docs",
1188 files
1189 .iter()
1190 .filter(|f| f.ends_with("/index.md"))
1191 .map(|f| f.to_string()),
1192 )
1193 }
1194
1195 #[test]
1199 fn 箇条書き記号とorderedの各形式がエントリとして検出される() {
1200 let text = "- [a](a.md)\n\n* [b](b.md)\n\n+ [c](c.md)\n\n1. [d](d.md)\n\n2) [e](e.md)\n";
1201 assert_eq!(dests(text), ["a.md", "b.md", "c.md", "d.md", "e.md"]);
1202 }
1203
1204 #[test]
1206 fn 入れ子の項目も任意の深さでエントリとして検出される() {
1207 let text = "- [a](a.md)\n - [b](sub/b.md)\n - [c](sub/c.md)\n";
1208 assert_eq!(dests(text), ["a.md", "sub/b.md", "sub/c.md"]);
1209 }
1210
1211 #[test]
1213 fn ルーズリストでも同じ結果になる() {
1214 let text = "- [a](a.md) — A\n\n- [b](b.md) — B\n";
1215 let es = parse_entries(text);
1216 assert_eq!(es.len(), 2);
1217 assert_eq!(es[0].description, "A");
1218 assert_eq!(es[1].description, "B");
1219 assert_eq!(&text[es[0].text_span.clone()], "a");
1220 assert_eq!(&text[es[1].text_span.clone()], "b");
1221 }
1222
1223 #[test]
1225 fn 太字で包まれたリンクで始まる項目はエントリではない() {
1226 assert!(parse_entries("- **[a](a.md)** — x\n").is_empty());
1227 }
1228
1229 #[test]
1231 fn タスクリストの項目はエントリではない() {
1232 assert!(parse_entries("- [ ] [a](a.md)\n- [x] [b](b.md)\n").is_empty());
1233 }
1234
1235 #[test]
1237 fn リンクで始まらない項目はエントリではない() {
1238 assert!(parse_entries("- see [a](a.md)\n- 説明のみ\n").is_empty());
1239 }
1240
1241 #[test]
1243 fn 段落中や見出し中のリンクはエントリではない() {
1244 let text = "intro [a](a.md)\n\n# [h](h.md)\n\n- [b](b.md)\n";
1245 assert_eq!(dests(text), ["b.md"]);
1246 }
1247
1248 #[test]
1250 fn 参照形式のリンクで始まる項目もエントリになる() {
1251 let text = "- [a][ref]\n- [b][]\n- [c]\n\n[ref]: a.md\n[b]: b.md\n[c]: c.md\n";
1252 let es = parse_entries(text);
1253 assert_eq!(
1254 es.iter().map(|e| e.dest.as_str()).collect::<Vec<_>>(),
1255 ["a.md", "b.md", "c.md"]
1256 );
1257 assert_eq!(&text[es[0].text_span.clone()], "a");
1258 assert_eq!(&text[es[1].text_span.clone()], "b");
1259 assert_eq!(&text[es[2].text_span.clone()], "c");
1260 }
1261
1262 #[test]
1264 fn リンクのtitle属性は無視される() {
1265 let text = "- [a](a.md \"tt\") — d\n";
1266 let es = parse_entries(text);
1267 assert_eq!(es.len(), 1);
1268 assert_eq!(es[0].dest, "a.md");
1269 assert_eq!(es[0].description, "d");
1270 }
1271
1272 #[test]
1274 fn 複数行にわたる項目も1つのエントリになる() {
1275 let text = "- [a](a.md) — one\n two\n- [b](b.md)\n";
1276 let es = parse_entries(text);
1277 assert_eq!(es.len(), 2);
1278 assert_eq!(es[0].dest, "a.md");
1279 }
1280
1281 #[test]
1283 fn bomがあっても同じエントリが検出される() {
1284 let text = "\u{feff}- [a](a.md) — d\n";
1285 let es = parse_entries(text);
1286 assert_eq!(es.len(), 1);
1287 assert_eq!(&text[es[0].text_span.clone()], "a");
1288 assert_eq!(&text[es[0].tail_span.clone()], " — d");
1289 }
1290
1291 #[test]
1293 fn okf_version等のfrontmatter付き索引でも検出される() {
1294 let text = "---\nokf_version: 1\n---\n\n- [a](a.md)\n";
1295 let es = parse_entries(text);
1296 assert_eq!(es.len(), 1);
1297 assert_eq!(&text[es[0].text_span.clone()], "a");
1298 }
1299
1300 #[test]
1304 fn 空のリンク文は空文字列でspanは開き括弧の直後になる() {
1305 let text = "- [](x.md)\n";
1306 let es = parse_entries(text);
1307 assert_eq!(es.len(), 1);
1308 assert_eq!(es[0].link_text, "");
1309 let pos = text.find('[').unwrap() + 1;
1310 assert_eq!(es[0].text_span, pos..pos);
1311 }
1312
1313 #[test]
1315 fn リンク文のコードスパンや入れ子の角括弧はソースのまま扱う() {
1316 let es = parse_entries("- [`a` b](x.md)\n- [T [x]](y.md)\n");
1317 assert_eq!(es[0].link_text, "`a` b");
1318 assert_eq!(es[1].link_text, "T [x]");
1319 }
1320
1321 #[test]
1323 fn 区切り4種を除いて要約を取り出す() {
1324 for sep in ['-', '–', '—', ':'] {
1325 let text = format!("- [a](a.md) {sep} desc\n");
1326 let es = parse_entries(&text);
1327 assert_eq!(es[0].separator, Some(sep), "{sep}");
1328 assert_eq!(es[0].description, "desc", "{sep}");
1329 }
1330 }
1331
1332 #[test]
1334 fn 区切りがなくても要約になり_区切りの二重指定は1文字だけ除く() {
1335 let es = parse_entries("- [a](a.md) desc\n- [b](b.md) — — x\n");
1336 assert_eq!(es[0].separator, None);
1337 assert_eq!(es[0].description, "desc");
1338 assert_eq!(es[1].separator, Some('—'));
1339 assert_eq!(es[1].description, "— x");
1340 }
1341
1342 #[test]
1344 fn 要約のないエントリは空の要約になる() {
1345 let es = parse_entries("- [a](a.md)\n");
1346 assert_eq!(es[0].description, "");
1347 assert_eq!(es[0].separator, None);
1348 assert!(es[0].tail_span.is_empty());
1349 }
1350
1351 #[test]
1353 fn 折り畳み参照の後ろから要約を取り出す() {
1354 let text = "- [a][] — desc\n\n[a]: a.md\n";
1355 let es = parse_entries(text);
1356 assert_eq!(es[0].description, "desc");
1357 assert_eq!(&text[es[0].tail_span.clone()], " — desc");
1358 }
1359
1360 #[test]
1362 fn 遅延継続行と複数行の要約を含む() {
1363 let lazy = parse_entries("- [a](a.md) — first\nsecond\n");
1364 assert_eq!(lazy[0].description, "first\nsecond");
1365 let indented = parse_entries("- [a](a.md) — first\n second\n");
1366 assert_eq!(indented[0].description, "first\n second");
1367 }
1368
1369 #[test]
1371 fn 要約に入れ子の子項目は含まれない() {
1372 let text = "- [a](a.md) — d\n - [b](b.md) — e\n";
1373 let es = parse_entries(text);
1374 assert_eq!(es.len(), 2);
1375 assert_eq!(es[0].description, "d");
1376 assert_eq!(&text[es[0].tail_span.clone()], " — d");
1377 assert_eq!(es[1].description, "e");
1378 }
1379
1380 #[test]
1382 fn line_spanは先頭行から改行までを含む() {
1383 let text = "- [a](a.md) — d\r\n - [b](b.md)\r\n- [c](c.md) — z";
1384 let es = parse_entries(text);
1385 assert_eq!(&text[es[0].line_span.clone()], "- [a](a.md) — d\r\n");
1386 assert_eq!(&text[es[1].line_span.clone()], " - [b](b.md)\r\n");
1387 assert_eq!(&text[es[2].line_span.clone()], "- [c](c.md) — z");
1388 }
1389
1390 #[test]
1392 fn 角括弧のエスケープは比較時に戻される() {
1393 let files = [
1394 ("docs/index.md", "- [a\\[1\\]](a.md) — x \\] y\n"),
1395 (
1396 "docs/a.md",
1397 "---\ntype: BusinessNeed\ntitle: \"a[1]\"\ndescription: \"x ] y\"\n---\n",
1398 ),
1399 ];
1400 assert!(issues(&files).is_empty(), "{:?}", issues(&files));
1401 }
1402
1403 fn res(index_path: &str, dest: &str, files: &[&str]) -> Resolution {
1406 resolve(
1407 index_path,
1408 "docs",
1409 dest,
1410 &RepoFiles::new(files.iter().copied()),
1411 )
1412 }
1413
1414 #[test]
1416 fn 空とフラグメントのみは解決不能になる() {
1417 assert_eq!(
1418 res("docs/index.md", "", &[]),
1419 Resolution::Unresolvable("empty target")
1420 );
1421 assert_eq!(
1422 res("docs/index.md", "#x", &[]),
1423 Resolution::Unresolvable("empty target")
1424 );
1425 }
1426
1427 #[test]
1429 fn 外部リンクは無視される() {
1430 assert_eq!(
1431 res("docs/index.md", "https://example.com/a", &[]),
1432 Resolution::External
1433 );
1434 assert_eq!(
1435 res("docs/index.md", "mailto:a@example.com", &[]),
1436 Resolution::External
1437 );
1438 }
1439
1440 #[test]
1442 fn 絶対パスとリポジトリ外と根の外は解決不能になる() {
1443 assert_eq!(
1444 res("docs/index.md", "/abs.md", &[]),
1445 Resolution::Unresolvable("absolute path")
1446 );
1447 assert_eq!(
1448 res("docs/index.md", "../../out.md", &[]),
1449 Resolution::Unresolvable("escapes repository")
1450 );
1451 assert_eq!(
1452 res("docs/index.md", "../req/x.md", &["req/x.md"]),
1453 Resolution::Unresolvable("outside index root")
1454 );
1455 }
1456
1457 #[test]
1459 fn フラグメントを除いてファイルに解決される() {
1460 assert_eq!(
1461 res("docs/index.md", "a.md#sec", &["docs/a.md"]),
1462 Resolution::File("docs/a.md".to_string())
1463 );
1464 assert_eq!(
1465 res("docs/sub/index.md", "../a.md", &["docs/a.md"]),
1466 Resolution::File("docs/a.md".to_string())
1467 );
1468 }
1469
1470 #[test]
1472 fn パーセントデコードされ_デコード後のシャープは再分割されない() {
1473 assert_eq!(
1474 res("docs/index.md", "%E9%9B%86.md", &["docs/集.md"]),
1475 Resolution::File("docs/集.md".to_string())
1476 );
1477 assert_eq!(
1478 res("docs/index.md", "a%23b.md", &["docs/a#b.md"]),
1479 Resolution::File("docs/a#b.md".to_string())
1480 );
1481 assert_eq!(
1482 res("docs/index.md", "100%.md", &["docs/100%.md"]),
1483 Resolution::File("docs/100%.md".to_string())
1484 );
1485 }
1486
1487 #[test]
1489 fn ディレクトリを指す3形は同じディレクトリエントリになる() {
1490 let files = ["docs/sub/index.md", "docs/sub/x.md"];
1491 let want = Resolution::Directory("docs/sub".to_string());
1492 assert_eq!(res("docs/index.md", "sub/", &files), want);
1493 assert_eq!(res("docs/index.md", "sub", &files), want);
1494 assert_eq!(res("docs/index.md", "sub/index.md", &files), want);
1495 }
1496
1497 #[test]
1499 fn スラッシュなしでも配下にファイルがあればディレクトリになる() {
1500 assert_eq!(
1501 res("docs/index.md", "sub", &["docs/sub/x.md"]),
1502 Resolution::Directory("docs/sub".to_string())
1503 );
1504 }
1505
1506 #[test]
1508 fn 自身と祖先のディレクトリは解決不能になる() {
1509 let why = Resolution::Unresolvable("self or ancestor directory");
1510 assert_eq!(res("docs/index.md", "./", &[]), why);
1511 assert_eq!(res("docs/index.md", "index.md", &[]), why);
1512 assert_eq!(res("docs/a/index.md", "../", &[]), why);
1513 assert_eq!(res("docs/a/index.md", "../index.md", &[]), why);
1514 assert_eq!(res("docs/a/index.md", "./", &[]), why);
1515 }
1516
1517 #[test]
1519 fn 存在しないファイルは文書エントリとして扱う() {
1520 assert_eq!(
1521 res("docs/index.md", "missing.md", &[]),
1522 Resolution::File("docs/missing.md".to_string())
1523 );
1524 }
1525
1526 #[test]
1530 fn titleはフロントマターのtitleを優先し前後の空白を除く() {
1531 let t = fm("---\ntype: X\ntitle: \" Front \"\n---\n# Heading\n");
1532 assert_eq!(document_title("docs/a.md", &t), "Front");
1533 }
1534
1535 #[test]
1537 fn titleが空なら最初のh1にフォールバックする() {
1538 let t = fm("---\ntype: X\ntitle: \" \"\n---\n# Heading\n");
1539 assert_eq!(document_title("docs/a.md", &t), "Heading");
1540 }
1541
1542 #[test]
1544 fn h1はatx閉じのシャープを含まない() {
1545 let t = fm("---\ntype: X\n---\n# Hello World #\n");
1546 assert_eq!(document_title("docs/a.md", &t), "Hello World");
1547 }
1548
1549 #[test]
1551 fn h1はsetext形式でも取れて下線を含まない() {
1552 let t = fm("---\ntype: X\n---\nHello World\n===\n");
1553 assert_eq!(document_title("docs/a.md", &t), "Hello World");
1554 }
1555
1556 #[test]
1558 fn h1のコードスパンはソースのまま含む() {
1559 let t = fm("---\ntype: X\n---\n# `code` and *em*\n");
1560 assert_eq!(document_title("docs/a.md", &t), "`code` and *em*");
1561 }
1562
1563 #[test]
1565 fn コードブロック内のシャープ行はh1として扱わない() {
1566 let t = fm("---\ntype: X\n---\n```\n# not heading\n```\n\n# Real\n");
1567 assert_eq!(document_title("docs/a.md", &t), "Real");
1568 }
1569
1570 #[test]
1572 fn h1以外の見出しは無視されファイル名にフォールバックする() {
1573 let t = fm("---\ntype: X\n---\n## second level\n");
1574 assert_eq!(document_title("docs/foo-bar.md", &t), "foo-bar");
1575 }
1576
1577 fn collect(files: &[(&str, &str)]) -> RootFiles {
1578 collect_documents("docs", &texts_of(files))
1579 }
1580
1581 #[test]
1583 fn descriptionの有無と空と改行の扱い() {
1584 let files = [
1585 ("docs/none.md", "---\ntype: X\n---\n"),
1586 ("docs/empty.md", "---\ntype: X\ndescription: \"\"\n---\n"),
1587 (
1588 "docs/newline.md",
1589 "---\ntype: X\ndescription: \"a\\nb\"\n---\n",
1590 ),
1591 (
1592 "docs/folded.md",
1593 "---\ntype: X\ndescription: >\n line1\n line2\n---\n",
1594 ),
1595 (
1596 "docs/block.md",
1597 "---\ntype: X\ndescription: |\n line1\n line2\n---\n",
1598 ),
1599 (
1600 "docs/trim.md",
1601 "---\ntype: X\ndescription: \" x \"\n---\n",
1602 ),
1603 ];
1604 let RootFiles {
1605 documents: docs,
1606 untyped,
1607 ..
1608 } = collect(&files);
1609 assert!(untyped.is_empty());
1610 let d = |p: &str| {
1611 docs.iter()
1612 .find(|d| d.path == p)
1613 .unwrap_or_else(|| panic!("{p}"))
1614 .description
1615 .clone()
1616 };
1617 assert_eq!(d("docs/none.md"), None);
1618 assert_eq!(d("docs/empty.md"), None);
1619 assert_eq!(d("docs/newline.md"), None);
1620 assert_eq!(d("docs/folded.md"), Some("line1 line2".to_string()));
1621 assert_eq!(d("docs/block.md"), None);
1622 assert_eq!(d("docs/trim.md"), Some("x".to_string()));
1623 }
1624
1625 #[test]
1627 fn 索引のtitleとdescriptionはディレクトリのタイトルと要約になる() {
1628 let files = [
1629 (
1630 "docs/a/index.md",
1631 "---\ntitle: \" Alpha \"\ndescription: \" DA \"\n---\n",
1632 ),
1633 ("docs/b/index.md", "---\ndescription: DB\n---\n"),
1634 (
1635 "docs/c/index.md",
1636 "---\ntitle: \"\"\ndescription: \"\"\n---\n",
1637 ),
1638 (
1639 "docs/d/index.md",
1640 "---\ntitle: \"x\\ny\"\ndescription: \"a\\nb\"\n---\n",
1641 ),
1642 ("docs/e/f/index.md", "plain\n"),
1643 (
1644 "docs/g/index.md",
1645 "---\ntype: BusinessNeed\ntitle: G\ndescription: DG\n---\n",
1646 ),
1647 ];
1648 let RootFiles {
1649 documents, indexes, ..
1650 } = collect(&files);
1651 assert!(documents.is_empty(), "{documents:?}");
1652 let m = |p: &str| {
1653 indexes
1654 .iter()
1655 .find(|m| m.path == p)
1656 .unwrap_or_else(|| panic!("{p}"))
1657 .clone()
1658 };
1659 assert_eq!(
1660 m("docs/a/index.md"),
1661 meta("docs/a/index.md", "Alpha", Some("DA"))
1662 );
1663 assert_eq!(
1664 m("docs/b/index.md"),
1665 meta("docs/b/index.md", "b", Some("DB"))
1666 );
1667 assert_eq!(m("docs/c/index.md"), meta("docs/c/index.md", "c", None));
1668 assert_eq!(m("docs/d/index.md"), meta("docs/d/index.md", "d", None));
1669 assert_eq!(m("docs/e/f/index.md"), meta("docs/e/f/index.md", "f", None));
1670 assert_eq!(
1671 m("docs/g/index.md"),
1672 meta("docs/g/index.md", "G", Some("DG"))
1673 );
1674 }
1675
1676 #[test]
1678 fn indexとlogは予約名で文書にならず索引として分類される() {
1679 let files = [
1680 ("docs/index.md", "- [a](a.md)\n"),
1681 ("docs/sub/index.md", "- [b](b.md)\n"),
1682 ("docs/log.md", "---\ntype: BusinessNeed\n---\n"),
1683 ("docs/a.md", "---\ntype: Note\ntitle: A\n---\n"),
1684 ];
1685 let RootFiles {
1686 documents: docs,
1687 untyped,
1688 indexes,
1689 } = collect(&files);
1690 assert_eq!(docs.len(), 1, "{docs:?}");
1691 assert!(untyped.is_empty(), "{untyped:?}");
1692 let mut paths: Vec<String> = indexes.into_iter().map(|m| m.path).collect();
1693 paths.sort();
1694 assert_eq!(paths, ["docs/index.md", "docs/sub/index.md"]);
1695 }
1696
1697 #[test]
1699 fn typeなしとフロントマターなしはuntypedで壊れたフロントマターは無視される() {
1700 let files = [
1701 ("docs/plain.md", "# no frontmatter\n"),
1702 ("docs/notype.md", "---\ntitle: x\n---\n"),
1703 ("docs/broken.md", "---\ntype: [unclosed\n---\n"),
1704 ];
1705 let RootFiles {
1706 documents: docs,
1707 untyped,
1708 ..
1709 } = collect(&files);
1710 assert!(docs.is_empty(), "{docs:?}");
1711 let mut untyped = untyped;
1712 untyped.sort();
1713 assert_eq!(untyped, ["docs/notype.md", "docs/plain.md"]);
1714 }
1715
1716 #[test]
1718 fn typeは任意の値でも文書になる() {
1719 let files = [("docs/a.md", "---\ntype: Note\ntitle: A\n---\n")];
1720 let RootFiles {
1721 documents: docs, ..
1722 } = collect(&files);
1723 assert_eq!(docs.len(), 1, "{docs:?}");
1724 assert_eq!(docs[0].path, "docs/a.md");
1725 assert_eq!(docs[0].title, "A");
1726 }
1727
1728 #[test]
1732 fn 根のみの索引で整合していればfindingは出ない() {
1733 let a = typed("A", Some("D1"));
1734 let b = typed("B", Some("D2"));
1735 let files = [
1736 ("docs/index.md", "- [A](a.md) — D1\n- [B](b.md) - D2\n"),
1737 ("docs/a.md", a.as_str()),
1738 ("docs/b.md", b.as_str()),
1739 ];
1740 assert!(issues(&files).is_empty(), "{:?}", issues(&files));
1741 }
1742
1743 #[test]
1745 fn 入れ子の索引で子の文書は子の索引が担当する() {
1746 let sub = index_with(Some("sub"), Some("S"));
1747 let a = typed("A", Some("DA"));
1748 let b = typed("B", Some("DB"));
1749 let files = [
1750 ("docs/index.md", "- [A](a.md) — DA\n- [sub](sub/) — S\n"),
1751 ("docs/sub/index.md", sub.as_str()),
1752 ("docs/a.md", a.as_str()),
1753 ("docs/sub/b.md", b.as_str()),
1754 ];
1755 assert!(issues(&files).is_empty(), "{:?}", issues(&files));
1756 }
1757
1758 #[test]
1760 fn 親が子の担当文書を直接列挙しても_同じ文書の重複列挙もfindingにならない() {
1761 let sub = index_with(Some("sub"), Some("S"));
1762 let a = typed("A", Some("DA"));
1763 let b = typed("B", Some("DB"));
1764 let files = [
1765 (
1766 "docs/index.md",
1767 "- [A](a.md) — DA\n- [A](a.md) — DA\n- [sub](sub/index.md) — S\n- [B](sub/b.md) — DB\n",
1768 ),
1769 ("docs/sub/index.md", sub.as_str()),
1770 ("docs/a.md", a.as_str()),
1771 ("docs/sub/b.md", b.as_str()),
1772 ];
1773 assert!(issues(&files).is_empty(), "{:?}", issues(&files));
1774 }
1775
1776 #[test]
1778 fn 担当索引が列挙していなければ他の索引が列挙していてもnot_listedになる() {
1779 let a = typed("A", Some("DA"));
1780 let b = typed("B", Some("DB"));
1781 let files = [
1782 (
1783 "docs/index.md",
1784 "- [A](a.md) — DA\n- [sub](sub/) — S\n- [B](sub/b.md) — DB\n",
1785 ),
1786 ("docs/sub/index.md", "intro only\n"),
1787 ("docs/a.md", a.as_str()),
1788 ("docs/sub/b.md", b.as_str()),
1789 ];
1790 let set = issues(&files);
1791 assert!(has(&set, ReasonCode::NotListed, "docs/sub/b.md"), "{set:?}");
1792 assert!(!has(&set, ReasonCode::NotListed, "docs/a.md"));
1793 }
1794
1795 #[test]
1797 fn 中間ディレクトリに索引がなくても最も近い祖先の索引が親になる() {
1798 let c = typed("C", Some("DC"));
1799 let ab = "---\ntitle: a/b\ndescription: AB\n---\n- [C](c.md) — DC\n";
1800 let files = [
1801 ("docs/index.md", "- [a/b](a/b/) — AB\n"),
1802 ("docs/a/b/index.md", ab),
1803 ("docs/a/b/c.md", c.as_str()),
1804 ];
1805 assert!(issues(&files).is_empty(), "{:?}", issues(&files));
1806 }
1807
1808 #[test]
1810 fn 自前の索引を持たない中間ディレクトリへのエントリはbroken_entryになる() {
1811 let c = typed("C", Some("DC"));
1812 let files = [
1813 ("docs/index.md", "- [a](a/) — A\n"),
1814 ("docs/a/b/index.md", "- [C](c.md) — DC\n"),
1815 ("docs/a/b/c.md", c.as_str()),
1816 ];
1817 let set = issues(&files);
1818 assert_eq!(
1819 detail_of(&set, ReasonCode::BrokenEntry, "docs/index.md -> a/"),
1820 "directory has no index.md"
1821 );
1822 assert!(has(&set, ReasonCode::UnlinkedIndex, "docs/a/b/index.md"));
1823 }
1824
1825 #[test]
1827 fn 設定した根が配下のディレクトリでも担当範囲はその根の中だけになる() {
1828 let g = typed("G", Some("DG"));
1829 let files = [
1830 ("docs/guide/index.md", "- [G](g.md) — DG\n"),
1831 ("docs/guide/g.md", g.as_str()),
1832 ("docs/other.md", "---\ntype: X\n---\n"),
1833 ("docs/untyped.md", "plain\n"),
1834 ];
1835 let set = issues_with(&["docs/guide"], &[], &files);
1836 assert!(set.is_empty(), "{set:?}");
1837 }
1838
1839 #[test]
1841 fn 根の索引がなく文書があればmissing_indexで他の検査は省く() {
1842 let a = typed("A", Some("DA"));
1843 let files = [
1844 ("docs/a.md", a.as_str()),
1845 ("docs/nodesc.md", "---\ntype: X\ntitle: N\n---\n"),
1846 ("docs/sub/index.md", "- [gone](gone.md)\n"),
1847 ("docs/u.md", "plain\n"),
1848 ];
1849 let set = issues(&files);
1850 assert!(has(&set, ReasonCode::MissingIndex, "docs"), "{set:?}");
1851 assert!(has(&set, ReasonCode::UntypedFile, "docs/u.md"));
1852 for r in [
1853 ReasonCode::NotListed,
1854 ReasonCode::BrokenEntry,
1855 ReasonCode::UnlinkedIndex,
1856 ReasonCode::TitleMismatch,
1857 ] {
1858 assert!(set.iter().all(|i| i.reason != r), "{r:?} in {set:?}");
1859 }
1860 }
1861
1862 #[test]
1864 fn 文書が0件の根ではmissing_indexを出さない() {
1865 assert!(issues(&[]).is_empty());
1866 assert!(issues(&[("docs/log.md", "---\ntype: X\n---\n")]).is_empty());
1867 let set = issues(&[("docs/u.md", "plain\n")]);
1868 assert!(!set.iter().any(|i| i.reason == ReasonCode::MissingIndex));
1869 assert!(has(&set, ReasonCode::UntypedFile, "docs/u.md"));
1870 }
1871
1872 #[test]
1874 fn 列挙されていない文書はnot_listedになりidの妥当性に依存しない() {
1875 let files = [
1876 ("docs/index.md", "intro\n"),
1877 (
1878 "docs/a.md",
1879 "---\ntype: X\nid: \"bad id!\"\ndescription: D\n---\n",
1880 ),
1881 ("docs/b.md", "---\ntype: X\ndescription: D\n---\n"),
1882 ];
1883 let set = issues(&files);
1884 assert!(has(&set, ReasonCode::NotListed, "docs/a.md"), "{set:?}");
1885 assert!(has(&set, ReasonCode::NotListed, "docs/b.md"));
1886 assert_eq!(count(&set, ReasonCode::NotListed, "docs/a.md"), 1);
1887 }
1888
1889 #[test]
1891 fn typeのないmdはuntyped_fileでindexとlogと根の外は対象外() {
1892 let files = [
1893 ("docs/index.md", "- [x](x.md)\n"),
1894 ("docs/log.md", "plain\n"),
1895 ("docs/plain.md", "no frontmatter\n"),
1896 ("docs/notype.md", "---\ntitle: T\n---\n"),
1897 ("req/outside.md", "plain\n"),
1898 ];
1899 let set = issues(&files);
1900 assert!(
1901 has(&set, ReasonCode::UntypedFile, "docs/plain.md"),
1902 "{set:?}"
1903 );
1904 assert!(has(&set, ReasonCode::UntypedFile, "docs/notype.md"));
1905 assert!(!has(&set, ReasonCode::UntypedFile, "docs/index.md"));
1906 assert!(!has(&set, ReasonCode::UntypedFile, "docs/log.md"));
1907 assert!(!has(&set, ReasonCode::UntypedFile, "req/outside.md"));
1908 }
1909
1910 #[test]
1912 fn broken_entryのdetailとtargetの正準形() {
1913 let t = typed("T", Some("D"));
1914 let index = [
1915 "- [e]()\n",
1916 "- [a](#x)\n",
1917 "- [b](/abs.md)\n",
1918 "- [c](../../out.md)\n",
1919 "- [d](../req/x.md)\n",
1920 "- [s](./)\n",
1921 "- [f](missing.md)\n",
1922 "- [g](untyped.md)\n",
1923 "- [h](dirnoindex/)\n",
1924 "- [i](pic.png)\n",
1925 "- [j](vendor/x.md)\n",
1926 "- [k](node_modules/x.md)\n",
1927 "- [ext](https://example.com/x)\n",
1928 "- [mail](mailto:a@example.com)\n",
1929 ]
1930 .concat();
1931 let files = [
1932 ("docs/index.md", index.as_str()),
1933 ("docs/untyped.md", "plain\n"),
1934 ("docs/pic.png", ""),
1935 ("docs/dirnoindex/x.md", t.as_str()),
1936 ];
1937 let set = issues_with(&["docs"], &["vendor"], &files);
1938 let b = |dest: &str| {
1939 detail_of(
1940 &set,
1941 ReasonCode::BrokenEntry,
1942 &format!("docs/index.md -> {dest}"),
1943 )
1944 };
1945 assert_eq!(b(""), "empty target");
1946 assert_eq!(b("#x"), "empty target");
1947 assert_eq!(b("/abs.md"), "absolute path");
1948 assert_eq!(b("../../out.md"), "escapes repository");
1949 assert_eq!(b("../req/x.md"), "outside index root");
1950 assert_eq!(b("./"), "self or ancestor directory");
1951 assert_eq!(b("missing.md"), "target does not exist");
1952 assert_eq!(b("untyped.md"), "target has no `type`");
1953 assert_eq!(b("dirnoindex/"), "directory has no index.md");
1954 assert_eq!(b("pic.png"), "target is not a document");
1955 assert_eq!(b("vendor/x.md"), "target is excluded from scan");
1956 assert_eq!(b("node_modules/x.md"), "target is excluded from scan");
1957 assert!(!set.iter().any(|i| i.target.contains("example.com")));
1958 }
1959
1960 #[test]
1962 fn タイトルが一致しなければtitle_mismatchで対象は文書のパス() {
1963 let a = typed("Real", Some("D"));
1964 let files = [
1965 ("docs/index.md", "- [Wrong](a.md) — D\n"),
1966 ("docs/a.md", a.as_str()),
1967 ];
1968 let set = issues(&files);
1969 assert!(has(&set, ReasonCode::TitleMismatch, "docs/a.md"), "{set:?}");
1970 assert!(set
1971 .iter()
1972 .all(|i| i.reason != ReasonCode::DescriptionMismatch));
1973 }
1974
1975 #[test]
1977 fn 要約が一致しなければdescription_mismatchで空白は正規化しない() {
1978 let a = typed("A", Some("x y"));
1979 let files = [
1980 ("docs/index.md", "- [A](a.md) — x y\n"),
1981 ("docs/a.md", a.as_str()),
1982 ];
1983 let set = issues(&files);
1984 assert!(
1985 has(&set, ReasonCode::DescriptionMismatch, "docs/a.md"),
1986 "{set:?}"
1987 );
1988 assert!(set.iter().all(|i| i.reason != ReasonCode::TitleMismatch));
1989 }
1990
1991 #[test]
1993 fn 文書にdescriptionがなければdescription_mismatchを出さない() {
1994 let a = typed("A", None);
1995 let files = [
1996 ("docs/index.md", "- [A](a.md) — whatever\n"),
1997 ("docs/a.md", a.as_str()),
1998 ];
1999 let set = issues(&files);
2000 assert!(set
2001 .iter()
2002 .all(|i| i.reason != ReasonCode::DescriptionMismatch));
2003 assert!(has(&set, ReasonCode::MissingDescription, "docs/a.md"));
2004 }
2005
2006 #[test]
2008 fn 同じ文書を指す複数エントリはすべて比較され_findingは重複しない() {
2009 let a = typed("A", Some("D"));
2010 let files = [
2011 (
2012 "docs/index.md",
2013 "- [A](a.md) — D\n- [X](a.md) — D\n- [Y](a.md) — D\n",
2014 ),
2015 ("docs/a.md", a.as_str()),
2016 ];
2017 let set = issues(&files);
2018 assert_eq!(
2019 count(&set, ReasonCode::TitleMismatch, "docs/a.md"),
2020 1,
2021 "{set:?}"
2022 );
2023 }
2024
2025 #[test]
2027 fn descriptionがない_空_改行入りの文書はmissing_description() {
2028 let files = [
2029 ("docs/index.md", "- [A](a.md)\n- [B](b.md)\n- [C](c.md)\n"),
2030 ("docs/a.md", "---\ntype: X\ntitle: A\n---\n"),
2031 (
2032 "docs/b.md",
2033 "---\ntype: X\ntitle: B\ndescription: \"\"\n---\n",
2034 ),
2035 (
2036 "docs/c.md",
2037 "---\ntype: X\ntitle: C\ndescription: |\n l1\n l2\n---\n",
2038 ),
2039 ];
2040 let set = issues(&files);
2041 for p in ["docs/a.md", "docs/b.md", "docs/c.md"] {
2042 assert!(has(&set, ReasonCode::MissingDescription, p), "{p}: {set:?}");
2043 }
2044 }
2045
2046 #[test]
2048 fn missing_descriptionは未列挙の文書にも出る() {
2049 let files = [
2050 ("docs/index.md", "intro\n"),
2051 ("docs/a.md", "---\ntype: X\ntitle: A\n---\n"),
2052 ];
2053 let set = issues(&files);
2054 assert!(has(&set, ReasonCode::MissingDescription, "docs/a.md"));
2055 assert!(has(&set, ReasonCode::NotListed, "docs/a.md"));
2056 }
2057
2058 #[test]
2060 fn 子の索引にdescriptionがなければmissing_directory_descriptionで対象は子の索引のパス() {
2061 let b = typed("B", Some("DB"));
2062 let sub = index_with(Some("S"), None);
2063 let files = [
2064 ("docs/index.md", "- [S](sub/) — 何か\n"),
2065 ("docs/sub/index.md", sub.as_str()),
2066 ("docs/sub/b.md", b.as_str()),
2067 ];
2068 let set = issues(&files);
2069 assert!(
2070 has(
2071 &set,
2072 ReasonCode::MissingDirectoryDescription,
2073 "docs/sub/index.md"
2074 ),
2075 "{set:?}"
2076 );
2077 assert!(
2079 set.iter()
2080 .all(|i| i.reason != ReasonCode::DescriptionMismatch),
2081 "{set:?}"
2082 );
2083 assert!(set.iter().all(|i| i.reason != ReasonCode::UnlinkedIndex));
2084 }
2085
2086 #[test]
2088 fn 子のdescriptionが空や改行入りでもmissing_directory_description() {
2089 let b = typed("B", Some("DB"));
2090 for desc in ["\"\"", "\"a\\nb\""] {
2091 let sub = index_with(Some("S"), Some(desc));
2092 let files = [
2093 ("docs/index.md", "- [S](sub/) — x\n"),
2094 ("docs/sub/index.md", sub.as_str()),
2095 ("docs/sub/b.md", b.as_str()),
2096 ];
2097 let set = issues(&files);
2098 assert!(
2099 has(
2100 &set,
2101 ReasonCode::MissingDirectoryDescription,
2102 "docs/sub/index.md"
2103 ),
2104 "{desc}: {set:?}"
2105 );
2106 }
2107 }
2108
2109 #[test]
2111 fn エントリの要約が空でも子にdescriptionがあればdescription_mismatchだけが出る() {
2112 let b = typed("B", Some("DB"));
2113 let sub = index_with(Some("S"), Some("DS"));
2114 let files = [
2115 ("docs/index.md", "- [S](sub/)\n"),
2116 ("docs/sub/index.md", sub.as_str()),
2117 ("docs/sub/b.md", b.as_str()),
2118 ];
2119 let set = issues(&files);
2120 assert!(
2121 has(
2122 &set,
2123 ReasonCode::DescriptionMismatch,
2124 "docs/index.md -> sub/"
2125 ),
2126 "{set:?}"
2127 );
2128 assert!(
2129 set.iter()
2130 .all(|i| i.reason != ReasonCode::MissingDirectoryDescription),
2131 "{set:?}"
2132 );
2133 }
2134
2135 #[test]
2137 fn エントリの要約が空で子にもなければmissing_directory_descriptionだけが出る() {
2138 let b = typed("B", Some("DB"));
2139 let sub = index_with(Some("S"), None);
2140 let files = [
2141 ("docs/index.md", "- [S](sub/)\n"),
2142 ("docs/sub/index.md", sub.as_str()),
2143 ("docs/sub/b.md", b.as_str()),
2144 ];
2145 let set = issues(&files);
2146 assert_eq!(set.len(), 1, "{set:?}");
2147 assert!(has(
2148 &set,
2149 ReasonCode::MissingDirectoryDescription,
2150 "docs/sub/index.md"
2151 ));
2152 }
2153
2154 #[test]
2156 fn 子の索引と一致するディレクトリエントリは問題にならない() {
2157 let b = typed("B", Some("DB"));
2158 let sub = index_with(Some("S"), Some("DS"));
2159 let files = [
2160 ("docs/index.md", "- [S](sub/) — DS\n"),
2161 ("docs/sub/index.md", sub.as_str()),
2162 ("docs/sub/b.md", b.as_str()),
2163 ];
2164 assert!(issues(&files).is_empty());
2165 }
2166
2167 #[test]
2169 fn ディレクトリエントリのリンク文が子のtitleと違えばtitle_mismatch() {
2170 let b = typed("B", Some("DB"));
2171 let sub = index_with(Some("S"), Some("DS"));
2172 let files = [
2173 ("docs/index.md", "- [sub](sub/) — DS\n"),
2174 ("docs/sub/index.md", sub.as_str()),
2175 ("docs/sub/b.md", b.as_str()),
2176 ];
2177 let set = issues(&files);
2178 assert!(
2179 has(&set, ReasonCode::TitleMismatch, "docs/index.md -> sub/"),
2180 "{set:?}"
2181 );
2182 assert!(
2183 detail_of(&set, ReasonCode::TitleMismatch, "docs/index.md -> sub/").contains("`S`")
2184 );
2185 assert_eq!(set.len(), 1, "{set:?}");
2186 }
2187
2188 #[test]
2190 fn ディレクトリエントリの要約が子のdescriptionと違えばdescription_mismatch() {
2191 let b = typed("B", Some("DB"));
2192 let sub = index_with(Some("S"), Some("DS"));
2193 let files = [
2194 ("docs/index.md", "- [S](sub/) — 古い\n"),
2195 ("docs/sub/index.md", sub.as_str()),
2196 ("docs/sub/b.md", b.as_str()),
2197 ];
2198 let set = issues(&files);
2199 assert!(
2200 has(
2201 &set,
2202 ReasonCode::DescriptionMismatch,
2203 "docs/index.md -> sub/"
2204 ),
2205 "{set:?}"
2206 );
2207 assert_eq!(set.len(), 1, "{set:?}");
2208 }
2209
2210 #[test]
2212 fn 子にtitleがなければディレクトリ名と照合される() {
2213 let b = typed("B", Some("DB"));
2214 let sub = index_with(None, Some("DS"));
2215 let ok = [
2216 ("docs/index.md", "- [sub](sub/) — DS\n"),
2217 ("docs/sub/index.md", sub.as_str()),
2218 ("docs/sub/b.md", b.as_str()),
2219 ];
2220 assert!(issues(&ok).is_empty());
2221 let ng = [
2222 ("docs/index.md", "- [other](sub/) — DS\n"),
2223 ("docs/sub/index.md", sub.as_str()),
2224 ("docs/sub/b.md", b.as_str()),
2225 ];
2226 let set = issues(&ng);
2227 assert!(
2228 has(&set, ReasonCode::TitleMismatch, "docs/index.md -> sub/"),
2229 "{set:?}"
2230 );
2231 }
2232
2233 #[test]
2235 fn 中間索引を飛ばしたディレクトリエントリも子の索引で判定する() {
2236 let c = typed("C", Some("DC"));
2237 let ab = index_with(Some("AB"), None);
2238 let files = [
2239 ("docs/index.md", "- [AB](a/b/) — x\n"),
2240 ("docs/a/b/index.md", ab.as_str()),
2241 ("docs/a/b/c.md", c.as_str()),
2242 ];
2243 let set = issues(&files);
2244 assert!(
2245 has(
2246 &set,
2247 ReasonCode::MissingDirectoryDescription,
2248 "docs/a/b/index.md"
2249 ),
2250 "{set:?}"
2251 );
2252 assert!(set.iter().all(|i| i.reason != ReasonCode::UnlinkedIndex));
2253 }
2254
2255 #[test]
2257 fn 親から辿れない子の索引はunlinked_index() {
2258 let b = typed("B", Some("DB"));
2259 let files = [
2260 ("docs/index.md", "intro\n"),
2261 ("docs/sub/index.md", "- [B](b.md) — DB\n"),
2262 ("docs/sub/b.md", b.as_str()),
2263 ];
2264 let set = issues(&files);
2265 assert!(
2266 has(&set, ReasonCode::UnlinkedIndex, "docs/sub/index.md"),
2267 "{set:?}"
2268 );
2269 }
2270
2271 #[test]
2275 fn リンク文と要約を文書の値で置き換え区切りは既存を保つ() {
2276 let docs = [doc("docs/a.md", "A", Some("D"))];
2277 let files = ["docs/a.md"];
2278 assert_eq!(
2279 upd("- [Old](a.md) - old\n", &docs, &files, &[]),
2280 "- [A](a.md) - D\n"
2281 );
2282 assert_eq!(
2283 upd("- [Old](a.md) : old\n", &docs, &files, &[]),
2284 "- [A](a.md) : D\n"
2285 );
2286 assert_eq!(
2287 upd("- [Old](a.md)\n", &docs, &files, &[]),
2288 "- [A](a.md) — D\n"
2289 );
2290 }
2291
2292 #[test]
2294 fn descriptionのない文書は区切りも要約も付けない() {
2295 let docs = [doc("docs/a.md", "A", None)];
2296 assert_eq!(
2297 upd("- [Old](a.md) — x\n", &docs, &["docs/a.md"], &[]),
2298 "- [A](a.md)\n"
2299 );
2300 }
2301
2302 #[test]
2304 fn リンク先のソースは変更しない() {
2305 let docs = [doc("docs/a b.md", "A", Some("D"))];
2306 let files = ["docs/a b.md"];
2307 assert_eq!(
2308 upd("- [Old](<a b.md> \"t\") — x\n", &docs, &files, &[]),
2309 "- [A](<a b.md> \"t\") — D\n"
2310 );
2311 assert_eq!(
2312 upd("- [Old](a%20b.md) — x\n", &docs, &files, &[]),
2313 "- [A](a%20b.md) — D\n"
2314 );
2315 }
2316
2317 #[test]
2319 fn 参照形式の定義とラベルは変更しない() {
2320 let docs = [doc("docs/a.md", "A", Some("D"))];
2321 let files = ["docs/a.md"];
2322 assert_eq!(
2323 upd("- [Old][r] — x\n\n[r]: a.md\n", &docs, &files, &[]),
2324 "- [A][r] — D\n\n[r]: a.md\n"
2325 );
2326 let docs = [doc("docs/a.md", "Old", Some("D"))];
2328 assert_eq!(
2329 upd("- [Old][] — x\n\n[Old]: a.md\n", &docs, &files, &[]),
2330 "- [Old][] — D\n\n[Old]: a.md\n"
2331 );
2332 assert_eq!(
2333 upd("- [Old] — x\n\n[Old]: a.md\n", &docs, &files, &[]),
2334 "- [Old] — D\n\n[Old]: a.md\n"
2335 );
2336 }
2337
2338 #[test]
2340 fn タイトルの角括弧はエスケープされる() {
2341 let docs = [doc("docs/a.md", "a[1]", Some("D"))];
2342 assert_eq!(
2343 upd("- [Old](a.md) — x\n", &docs, &["docs/a.md"], &[]),
2344 "- [a\\[1\\]](a.md) — D\n"
2345 );
2346 }
2347
2348 #[test]
2350 fn 複数行エントリは1行に畳まれる() {
2351 let docs = [doc("docs/a.md", "A", Some("first second"))];
2352 let text = "- [A](a.md) — first\n second\n- [B](b.md) — DB\n";
2353 let docs = [docs[0].clone(), doc("docs/b.md", "B", Some("DB"))];
2354 assert_eq!(
2355 upd(text, &docs, &["docs/a.md", "docs/b.md"], &[]),
2356 "- [A](a.md) — first second\n- [B](b.md) — DB\n"
2357 );
2358 }
2359
2360 #[test]
2362 fn 同じ文書を指す重複エントリはすべて書き換える() {
2363 let docs = [doc("docs/a.md", "A", Some("D"))];
2364 assert_eq!(
2365 upd(
2366 "- [x](a.md) — 1\n- [y](./a.md) — 2\n",
2367 &docs,
2368 &["docs/a.md"],
2369 &[]
2370 ),
2371 "- [A](a.md) — D\n- [A](./a.md) — D\n"
2372 );
2373 }
2374
2375 #[test]
2377 fn すでに一致していればバイト列は変わらない() {
2378 let docs = [doc("docs/a.md", "A", Some("D"))];
2379 let text = "- [A](a.md) — D\n";
2380 assert_eq!(upd(text, &docs, &["docs/a.md"], &[]), text);
2381 }
2382
2383 #[test]
2385 fn bomは書き換え後も保存される() {
2386 let docs = [doc("docs/a.md", "A", Some("D"))];
2387 assert_eq!(
2388 upd("\u{feff}- [Old](a.md) — x\n", &docs, &["docs/a.md"], &[]),
2389 "\u{feff}- [A](a.md) — D\n"
2390 );
2391 }
2392
2393 #[test]
2397 fn 未列挙の文書がパス順に末尾へ追加される() {
2398 let docs = [
2399 doc("docs/c.md", "C", None),
2400 doc("docs/a.md", "A", Some("DA")),
2401 doc("docs/b.md", "B", Some("DB")),
2402 ];
2403 let text = "- [A](a.md) — DA\n";
2404 let files = ["docs/a.md", "docs/b.md", "docs/c.md"];
2405 assert_eq!(
2406 upd(text, &docs, &files, &[]),
2407 "- [A](a.md) — DA\n- [B](b.md) — DB\n- [C](c.md)\n"
2408 );
2409 }
2410
2411 #[test]
2413 fn 追加するパスは相対でエンコードされタイトルはエスケープされる() {
2414 let docs = [doc("docs/sub/a b(c)<d>%e#f\\g.md", "t[1]", Some("D"))];
2415 let files = ["docs/sub/a b(c)<d>%e#f\\g.md", "docs/x.md"];
2416 assert_eq!(
2417 upd("- [x](x.md)\n", &docs, &files, &[]),
2418 "- [x](x.md)\n- [t\\[1\\]](sub/a%20b%28c%29%3Cd%3E%25e%23f%5Cg.md) — D\n"
2419 );
2420 }
2421
2422 #[test]
2424 fn 末尾の改行と空行の規則() {
2425 let docs = [doc("docs/b.md", "B", Some("DB"))];
2426 let files = ["docs/b.md", "docs/x.md"];
2427 let item = "- [B](b.md) — DB\n";
2428 assert_eq!(
2430 upd("- [x](x.md)", &docs, &files, &[]),
2431 format!("- [x](x.md)\n{item}")
2432 );
2433 assert_eq!(
2435 upd("# Index\n\nintro\n", &docs, &files, &[]),
2436 format!("# Index\n\nintro\n\n{item}")
2437 );
2438 assert_eq!(upd("intro", &docs, &files, &[]), format!("intro\n\n{item}"));
2439 assert_eq!(upd("1. x\n", &docs, &files, &[]), format!("1. x\n{item}"));
2441 assert_eq!(upd("* x\n", &docs, &files, &[]), format!("* x\n{item}"));
2442 assert_eq!(upd("", &docs, &files, &[]), item);
2444 }
2445
2446 #[test]
2448 fn 改行コードは既存本文にcrlfがあればcrlfになる() {
2449 let docs = [doc("docs/b.md", "B", Some("DB"))];
2450 assert_eq!(
2451 upd("- [x](x.md)\r\n", &docs, &["docs/b.md", "docs/x.md"], &[]),
2452 "- [x](x.md)\r\n- [B](b.md) — DB\r\n"
2453 );
2454 assert_eq!(
2455 upd("# T\r\n\r\nintro\r\n", &docs, &["docs/b.md"], &[]),
2456 "# T\r\n\r\nintro\r\n\r\n- [B](b.md) — DB\r\n"
2457 );
2458 }
2459
2460 #[test]
2462 fn 辿れない子の索引はディレクトリエントリとして文書の後に追加される() {
2463 let docs = [doc("docs/a.md", "A", Some("DA"))];
2464 let metas = [
2465 meta("docs/zeta/index.md", "Zeta [z]", Some("DZ")),
2466 meta("docs/sub/index.md", "Sub", Some("DS")),
2467 ];
2468 let out = upd_meta(
2469 "- [x](x.md)\n",
2470 &docs,
2471 &metas,
2472 &["docs/a.md", "docs/x.md"],
2473 &["docs/zeta/index.md", "docs/sub/index.md"],
2474 );
2475 assert_eq!(
2476 out,
2477 "- [x](x.md)\n- [A](a.md) — DA\n- [Sub](sub/) — DS\n- [Zeta \\[z\\]](zeta/) — DZ\n"
2478 );
2479 }
2480
2481 #[test]
2483 fn 子に要約がなければ追加するディレクトリエントリは区切りを付けない() {
2484 let metas = [
2485 meta("docs/sub/index.md", "sub", None),
2486 meta("docs/two/index.md", "Two", None),
2487 ];
2488 let out = upd_meta(
2489 "- [x](x.md)\n",
2490 &[],
2491 &metas,
2492 &["docs/x.md"],
2493 &["docs/sub/index.md", "docs/two/index.md"],
2494 );
2495 assert_eq!(out, "- [x](x.md)\n- [sub](sub/)\n- [Two](two/)\n");
2496 }
2497
2498 #[test]
2500 fn ディレクトリエントリのリンク文と要約を子の索引の値で置き換える() {
2501 let metas = [meta("docs/sub/index.md", "Sub", Some("DS"))];
2502 let files = ["docs/sub/index.md"];
2503 let out = upd_meta("- [old](sub/) - 古い\n", &[], &metas, &files, &[]);
2504 assert_eq!(out, "- [Sub](sub/) - DS\n");
2505 let out = upd_meta("- [old](<sub/> \"t\")\n", &[], &metas, &files, &[]);
2507 assert_eq!(out, "- [Sub](<sub/> \"t\") — DS\n");
2508 let same = "- [Sub](sub/) — DS\n";
2510 assert_eq!(upd_meta(same, &[], &metas, &files, &[]), same);
2511 }
2512
2513 #[test]
2515 fn 子に要約がなければディレクトリエントリの後ろは空になる() {
2516 let metas = [meta("docs/sub/index.md", "sub", None)];
2517 let out = upd_meta(
2518 "- [sub](sub/) — 古い\n",
2519 &[],
2520 &metas,
2521 &["docs/sub/index.md"],
2522 &[],
2523 );
2524 assert_eq!(out, "- [sub](sub/)\n");
2525 }
2526
2527 #[test]
2529 fn ディレクトリエントリの折り畳み参照はリンク文を保ち後ろだけ更新する() {
2530 let metas = [meta("docs/sub/index.md", "Sub", Some("DS"))];
2531 let out = upd_meta(
2532 "- [sub][] — x\n\n[sub]: sub/\n",
2533 &[],
2534 &metas,
2535 &["docs/sub/index.md"],
2536 &[],
2537 );
2538 assert_eq!(out, "- [sub][] — DS\n\n[sub]: sub/\n");
2539 }
2540
2541 #[test]
2543 fn ディレクトリエントリの書き換えは冪等() {
2544 let metas = [meta("docs/sub/index.md", "Sub", Some("DS"))];
2545 let files = ["docs/sub/index.md"];
2546 let once = upd_meta("- [x](sub/) — y\n", &[], &metas, &files, &[]);
2547 let twice = upd_meta(&once, &[], &metas, &files, &[]);
2548 assert_eq!(once, twice);
2549 assert_eq!(once, "- [Sub](sub/) — DS\n");
2550 }
2551
2552 #[test]
2556 fn 存在しない文書へのエントリ行は削除される() {
2557 let docs = [doc("docs/a.md", "A", Some("D"))];
2558 let text = "- [A](a.md) — D\n- [G](gone.md) — g\n- [A](a.md) — D\n";
2559 assert_eq!(
2560 upd(text, &docs, &["docs/a.md"], &[]),
2561 "- [A](a.md) — D\n- [A](a.md) — D\n"
2562 );
2563 }
2564
2565 #[test]
2567 fn 削除した項目の子項目は残る() {
2568 let docs = [
2569 doc("docs/a.md", "A", Some("D")),
2570 doc("docs/b.md", "B", Some("DB")),
2571 ];
2572 let files = ["docs/a.md", "docs/b.md"];
2573 let text = "- [G](gone.md) — g\n - [A](a.md) — D\n- [B](b.md) — DB\n";
2574 assert_eq!(
2575 upd(text, &docs, &files, &[]),
2576 " - [A](a.md) — D\n- [B](b.md) — DB\n"
2577 );
2578 }
2579
2580 #[test]
2582 fn 削除時もbomは保存される() {
2583 let docs = [doc("docs/a.md", "A", Some("D"))];
2584 assert_eq!(
2585 upd(
2586 "\u{feff}- [G](gone.md)\n- [A](a.md) — D\n",
2587 &docs,
2588 &["docs/a.md"],
2589 &[]
2590 ),
2591 "\u{feff}- [A](a.md) — D\n"
2592 );
2593 }
2594
2595 #[test]
2597 fn 除外配下_typeなし_解決不能_ディレクトリは削除しない() {
2598 let docs = [doc("docs/a.md", "A", Some("D"))];
2599 let text = "- [A](a.md) — D\n\
2600 - [n](node_modules/x.md) — 1\n\
2601 - [u](untyped.md) — 2\n\
2602 - [o](../req/x.md) — 3\n\
2603 - [d](sub/) — 4\n\
2604 - [e](#x) — 5\n";
2605 let files = ["docs/a.md", "docs/untyped.md"];
2606 assert_eq!(upd(text, &docs, &files, &[]), text);
2607 }
2608
2609 #[test]
2613 fn 索引がなければ見出しなしの平坦なリストを作る() {
2614 let docs = [
2615 doc("docs/sub/b.md", "B", None),
2616 doc("docs/a.md", "A", Some("DA")),
2617 ];
2618 let out = upd("", &docs, &["docs/a.md", "docs/sub/b.md"], &[]);
2619 assert_eq!(out, "- [A](a.md) — DA\n- [B](sub/b.md)\n");
2620 assert!(!out.contains('#'));
2621 }
2622
2623 #[test]
2625 fn 新規作成でも子索引のディレクトリエントリは子のtitleと要約を使う() {
2626 let docs = [doc("docs/a.md", "A", Some("DA"))];
2627 let metas = [
2628 meta("docs/sub/index.md", "Sub", Some("DS")),
2629 meta("docs/two/index.md", "two", None),
2630 ];
2631 let out = upd_meta(
2632 "",
2633 &docs,
2634 &metas,
2635 &["docs/a.md", "docs/sub/index.md", "docs/two/index.md"],
2636 &["docs/sub/index.md", "docs/two/index.md"],
2637 );
2638 assert_eq!(out, "- [A](a.md) — DA\n- [Sub](sub/) — DS\n- [two](two/)\n");
2639 }
2640
2641 #[test]
2645 fn エントリ行以外のバイト列は変更されない() {
2646 let docs = [doc("docs/a.md", "A", Some("D"))];
2647 let files = ["docs/a.md", "docs/sub/index.md"];
2648 let metas = [meta("docs/sub/index.md", "sub", Some("S"))];
2649 let text = "\u{feff}# Index\r\n\r\nintro text\r\n\r\n## Sec\r\n\r\n- [Old](a.md) — x\r\n - child [c](c.md)\r\n- **[b](b.md)** — bold\r\n- [sub](sub/) — S\r\n";
2650 let want = "\u{feff}# Index\r\n\r\nintro text\r\n\r\n## Sec\r\n\r\n- [A](a.md) — D\r\n - child [c](c.md)\r\n- **[b](b.md)** — bold\r\n- [sub](sub/) — S\r\n";
2651 assert_eq!(upd_meta(text, &docs, &metas, &files, &[]), want);
2652 }
2653
2654 #[test]
2656 fn 更新は冪等で2回目は同一文字列になる() {
2657 let docs = [
2658 doc("docs/a.md", "A", Some("D")),
2659 doc("docs/b.md", "B[1]", Some("DB")),
2660 doc("docs/c d.md", "C", None),
2661 ];
2662 let files = ["docs/a.md", "docs/b.md", "docs/c d.md"];
2663 let text = "# Index\n\n- [Old](a.md) - x\n y\n- [G](gone.md)\n";
2664 let once = upd(text, &docs, &files, &[]);
2665 let twice = upd(&once, &docs, &files, &[]);
2666 assert_eq!(once, twice);
2667 }
2668
2669 #[test]
2673 fn 参照形式とショートカットの後ろから要約を切り出す() {
2674 let text = "- [T][ref] — 要約1\n- [U] — 要約2\n\n[ref]: a.md\n[U]: b.md\n";
2675 let es = parse_entries(text);
2676 assert_eq!(es.len(), 2);
2677 assert_eq!(es[0].description, "要約1");
2678 assert_eq!(es[1].description, "要約2");
2679 }
2680
2681 #[test]
2683 fn パーセントエンコードされたドットドットはデコード後に正規化される() {
2684 assert_eq!(
2685 res("docs/sub/index.md", "%2E%2E/a.md", &["docs/a.md"]),
2686 Resolution::File("docs/a.md".to_string())
2687 );
2688 assert_eq!(
2689 res("docs/index.md", "%2E%2E/%2E%2E/x.md", &[]),
2690 Resolution::Unresolvable("escapes repository")
2691 );
2692 }
2693
2694 #[test]
2696 fn フラグメント付きの索引ファイル指定もディレクトリエントリになる() {
2697 assert_eq!(
2698 res("docs/index.md", "sub/index.md#frag", &["docs/sub/index.md"]),
2699 Resolution::Directory("docs/sub".to_string())
2700 );
2701 }
2702
2703 #[test]
2705 fn setextのレベル2下線はh1として扱わない() {
2706 let t = fm("---\ntype: X\n---\nIntro\n---\n");
2707 assert_eq!(document_title("docs/foo.md", &t), "foo");
2708 }
2709
2710 #[test]
2712 fn 改行を含むtitleは無いものとしてh1にフォールバックする() {
2713 let t = fm("---\ntype: X\ntitle: \"a\\nb\"\n---\n# Heading\n");
2714 assert_eq!(document_title("docs/foo.md", &t), "Heading");
2715 let t = fm("---\ntype: X\ntitle: \"a\\nb\"\n---\n");
2716 assert_eq!(document_title("docs/foo.md", &t), "foo");
2717 }
2718
2719 #[test]
2721 fn missing_index時は索引側の6種のfindingがすべて出ない() {
2722 let a = typed("A", Some("DA"));
2723 let b = typed("B", Some("DB"));
2724 let files = [
2725 ("docs/a.md", a.as_str()),
2726 (
2727 "docs/sub/index.md",
2728 "- [X](../a.md) — other\n- [gone](gone.md)\n- [d](dir/)\n- [e](dir2/)\n",
2729 ),
2730 ("docs/sub/dir/index.md", "intro\n"),
2731 ("docs/sub/dir/b.md", b.as_str()),
2732 ("docs/sub/dir2/index.md", "intro\n"),
2733 ];
2734 let set = issues(&files);
2735 assert!(has(&set, ReasonCode::MissingIndex, "docs"), "{set:?}");
2736 for r in [
2737 ReasonCode::NotListed,
2738 ReasonCode::BrokenEntry,
2739 ReasonCode::TitleMismatch,
2740 ReasonCode::DescriptionMismatch,
2741 ReasonCode::MissingDirectoryDescription,
2742 ReasonCode::UnlinkedIndex,
2743 ] {
2744 assert!(set.iter().all(|i| i.reason != r), "{r:?} in {set:?}");
2745 }
2746 }
2747
2748 #[test]
2750 fn 索引自身を指すエントリは自己か祖先として壊れている() {
2751 let a = typed("A", Some("D"));
2752 let files = [
2753 (
2754 "docs/index.md",
2755 "- [A](a.md) — D\n- [me](index.md)\n- [s](sub/index.md)\n",
2756 ),
2757 ("docs/a.md", a.as_str()),
2758 ];
2759 let set = issues(&files);
2760 assert_eq!(
2761 detail_of(&set, ReasonCode::BrokenEntry, "docs/index.md -> index.md"),
2762 "self or ancestor directory"
2763 );
2764 assert_eq!(
2765 detail_of(
2766 &set,
2767 ReasonCode::BrokenEntry,
2768 "docs/index.md -> sub/index.md"
2769 ),
2770 "directory has no index.md"
2771 );
2772 }
2773
2774 #[test]
2776 fn 同じ文書を指す2本目だけ不一致でもfindingが出る() {
2777 let a = typed("A", Some("D"));
2778 let files = [
2779 ("docs/index.md", "- [A](a.md) — D\n- [X](a.md) — Y\n"),
2780 ("docs/a.md", a.as_str()),
2781 ];
2782 let set = issues(&files);
2783 assert!(has(&set, ReasonCode::TitleMismatch, "docs/a.md"), "{set:?}");
2784 assert!(has(&set, ReasonCode::DescriptionMismatch, "docs/a.md"));
2785 }
2786
2787 #[test]
2789 fn 区切りのen_dashは書き戻しで保持される() {
2790 let docs = [doc("docs/a.md", "A", Some("D"))];
2791 assert_eq!(
2792 upd("- [Old](a.md) – old\n", &docs, &["docs/a.md"], &[]),
2793 "- [A](a.md) – D\n"
2794 );
2795 }
2796
2797 #[test]
2799 fn 折り畳み参照のラベルは変えず要約だけ更新されtitle_mismatchが残る() {
2800 let docs = [doc("docs/a.md", "A", Some("D"))];
2801 let text = "- [旧][] — x\n\n[旧]: a.md\n";
2802 let out = upd(text, &docs, &["docs/a.md"], &[]);
2803 assert_eq!(out, "- [旧][] — D\n\n[旧]: a.md\n");
2804 let a = typed("A", Some("D"));
2805 let set = issues(&[("docs/index.md", out.as_str()), ("docs/a.md", a.as_str())]);
2806 assert!(has(&set, ReasonCode::TitleMismatch, "docs/a.md"), "{set:?}");
2807 assert!(set
2808 .iter()
2809 .all(|i| i.reason != ReasonCode::DescriptionMismatch));
2810 }
2811
2812 #[test]
2814 fn 直前に項目がある状態で削除すると子項目は直前の項目の下に残る() {
2815 let docs = [
2816 doc("docs/a.md", "A", Some("D")),
2817 doc("docs/b.md", "B", Some("DB")),
2818 ];
2819 let files = ["docs/a.md", "docs/b.md"];
2820 let text = "- [A](a.md) — D\n- [G](gone.md) — g\n - [B](b.md) — DB\n";
2821 assert_eq!(
2822 upd(text, &docs, &files, &[]),
2823 "- [A](a.md) — D\n - [B](b.md) — DB\n"
2824 );
2825 }
2826
2827 #[test]
2829 fn 複数行エントリの削除で末尾行まで消える() {
2830 let docs = [doc("docs/a.md", "A", Some("D"))];
2831 let text = "- [G](gone.md) — first\n second line\n- [A](a.md) — D\n";
2832 assert_eq!(upd(text, &docs, &["docs/a.md"], &[]), "- [A](a.md) — D\n");
2833 }
2834
2835 #[test]
2837 fn 書き込みはrename失敗時に同じディレクトリの一時ファイルを残さない() {
2838 let dir = tempfile::tempdir().unwrap();
2839 let target = dir.path().join("index.md");
2840 std::fs::create_dir(&target).unwrap();
2842 assert!(write_atomic(&target, "x").is_err());
2843 let names: Vec<String> = std::fs::read_dir(dir.path())
2844 .unwrap()
2845 .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
2846 .collect();
2847 assert_eq!(names, ["index.md"]);
2848
2849 let ok = dir.path().join("ok.md");
2850 write_atomic(&ok, "y").unwrap();
2851 assert_eq!(std::fs::read_to_string(&ok).unwrap(), "y");
2852 assert!(std::fs::read_dir(dir.path()).unwrap().all(|e| !e
2853 .unwrap()
2854 .file_name()
2855 .to_string_lossy()
2856 .ends_with(".tmp")));
2857 }
2858
2859 #[test]
2863 fn エスケープされた角括弧を含むリンク文は壊れずに冪等に書き換わる() {
2864 let docs = [doc("docs/a.md", "[x] y", Some("D"))];
2865 let files = ["docs/a.md"];
2866 let once = upd("- [\\[x\\]](a.md) — d\n", &docs, &files, &[]);
2867 assert_eq!(once, "- [\\[x\\] y](a.md) — D\n");
2868 assert_eq!(upd(&once, &docs, &files, &[]), once);
2869 }
2870
2871 #[test]
2873 fn 各リンク形式でリンク文の範囲は元のソースの閉じ括弧までになる() {
2874 let e = &parse_entries("- [a\\]b](a.md \"t\") — d\n")[0];
2875 assert_eq!(e.link_text, "a\\]b");
2876 let e = &parse_entries("- [a *b*][r] — d\n\n[r]: a.md\n")[0];
2877 assert_eq!(e.link_text, "a *b*");
2878 assert_eq!(e.description, "d");
2879 let e = &parse_entries("- [a\\]][] — d\n\n[a\\]]: a.md\n")[0];
2880 assert_eq!(e.link_text, "a\\]");
2881 assert_eq!(e.description, "d");
2882 let e = &parse_entries("- [a] — d\n\n[a]: a.md\n")[0];
2883 assert_eq!(e.link_text, "a");
2884 assert_eq!(e.description, "d");
2885 let e = &parse_entries("- [](a.md) — d\n")[0];
2886 assert_eq!(e.link_text, "");
2887 }
2888
2889 #[test]
2891 fn ハードブレークとソフトブレークの直後の要約は1行に畳まれる() {
2892 let docs = [doc("docs/a.md", "A", Some("D"))];
2893 let files = ["docs/a.md"];
2894 assert_eq!(
2895 upd("- [a](a.md) \n hard\n", &docs, &files, &[]),
2896 "- [A](a.md) — D\n"
2897 );
2898 assert_eq!(
2899 upd("- [a](a.md)\n soft\n", &docs, &files, &[]),
2900 "- [A](a.md) — D\n"
2901 );
2902 }
2903
2904 #[test]
2906 fn バックスラッシュはエスケープされ再パースで元のタイトルに戻る() {
2907 let docs = [doc("docs/a.md", "a\\", Some("D"))];
2908 let out = upd("", &docs, &["docs/a.md"], &[]);
2909 assert_eq!(out, "- [a\\\\](a.md) — D\n");
2910 let e = &parse_entries(&out)[0];
2911 assert_eq!(unescape_brackets(&e.link_text), "a\\");
2912 assert_eq!(upd(&out, &docs, &["docs/a.md"], &[]), out);
2913 assert_eq!(unescape_brackets("\\\\ \\[ \\] \\x"), "\\ [ ] \\x");
2914 }
2915
2916 #[test]
2918 fn 要約の角括弧とバックスラッシュもエスケープされ比較で不一致にならない() {
2919 let docs = [doc("docs/a.md", "T", Some("a [b] c\\d"))];
2920 let files = ["docs/a.md"];
2921 let once = upd("- [T](a.md) — old\n", &docs, &files, &[]);
2922 assert_eq!(once, "- [T](a.md) — a \\[b\\] c\\\\d\n");
2923 let e = &parse_entries(&once)[0];
2924 assert_eq!(unescape_brackets(&e.description), "a [b] c\\d");
2925 assert_eq!(upd(&once, &docs, &files, &[]), once);
2926 }
2927
2928 #[test]
2930 fn 除外名の配下を指す存在しないエントリ行は削除されない() {
2931 let docs = [doc("docs/a.md", "A", Some("D"))];
2932 let files = ["docs/a.md"];
2933 let text = "- [A](a.md) — D\n- [X](vendor/x.md) — x\n- [G](gone.md) — g\n";
2934 let excluded = vec!["vendor".to_string()];
2935 let out = update_index(
2936 text,
2937 "docs/index.md",
2938 &tree_of(&files),
2939 &docs,
2940 &[],
2941 &RepoFiles::new(files.iter().copied()),
2942 &[],
2943 &excluded,
2944 );
2945 assert_eq!(out, "- [A](a.md) — D\n- [X](vendor/x.md) — x\n");
2946 }
2947
2948 #[test]
2950 fn 改行crを含むdescriptionは要約なし() {
2951 let files = [("docs/cr.md", "---\ntype: X\ndescription: \"a\\rb\"\n---\n")];
2952 let docs = collect(&files).documents;
2953 assert_eq!(docs[0].description, None);
2954 }
2955
2956 #[test]
2958 fn 子の索引のフロントマターが壊れていればタイトルと要約の照合をしない() {
2959 let b = typed("B", Some("DB"));
2960 let files = [
2961 ("docs/index.md", "- [S](sub/) — 何か\n"),
2962 (
2963 "docs/sub/index.md",
2964 "---\ntitle: [\n---\n- [B](b.md) — DB\n",
2965 ),
2966 ("docs/sub/b.md", b.as_str()),
2967 ];
2968 let set = issues(&files);
2969 for r in [
2970 ReasonCode::TitleMismatch,
2971 ReasonCode::DescriptionMismatch,
2972 ReasonCode::MissingDirectoryDescription,
2973 ] {
2974 assert!(set.iter().all(|i| i.reason != r), "{set:?}");
2975 }
2976 }
2977
2978 #[test]
2980 fn 子の索引のフロントマターが壊れていれば親のエントリを書き換えない() {
2981 let files = ["docs/index.md", "docs/sub/index.md"];
2982 let mut m = meta("docs/sub/index.md", "sub", None);
2983 m.invalid = true;
2984 let text = "- [Old](sub/) — 古い\n";
2985 assert_eq!(upd_meta(text, &[], &[m.clone()], &files, &[]), text);
2986 assert_eq!(upd_meta("", &[], &[m], &files, &["docs/sub/index.md"]), "");
2988 }
2989
2990 #[test]
2992 fn 最初のh1が空なら次のh1を探さずファイル名にフォールバックする() {
2993 let t = fm("---\ntype: X\n---\n#\n\n# Second\n");
2994 assert_eq!(document_title("docs/foo.md", &t), "foo");
2995 }
2996
2997 #[test]
2999 fn 強調を含むリンク文はソース文字列のまま扱われる() {
3000 let e = &parse_entries("- [*a*](x.md)\n")[0];
3001 assert_eq!(e.link_text, "*a*");
3002 }
3003
3004 #[test]
3006 fn 山括弧とtitle付きの宛先のbroken_entryのtargetは解釈後のdestinationになる() {
3007 let files = [
3008 (
3009 "docs/index.md",
3010 "- [x](<gone file.md> \"t\")\n- [a](a.md)\n",
3011 ),
3012 ("docs/a.md", &typed("a", Some("d"))),
3013 ];
3014 let set = issues(&files);
3015 assert_eq!(
3016 detail_of(
3017 &set,
3018 ReasonCode::BrokenEntry,
3019 "docs/index.md -> gone file.md"
3020 ),
3021 "target does not exist"
3022 );
3023 }
3024
3025 #[test]
3027 fn 根が子孫を列挙していても直近の親が列挙していなければunlinked_indexになる() {
3028 let files = [
3029 ("docs/index.md", "- [a](a/) — A\n- [a/b](a/b/) — B\n"),
3030 ("docs/a/index.md", "- [x](x.md)\n"),
3031 ("docs/a/x.md", &typed("x", Some("d"))),
3032 ("docs/a/b/index.md", "- [y](y.md)\n"),
3033 ("docs/a/b/y.md", &typed("y", Some("d"))),
3034 ];
3035 let set = issues(&files);
3036 assert!(has(&set, ReasonCode::UnlinkedIndex, "docs/a/b/index.md"));
3037 assert!(!has(&set, ReasonCode::UnlinkedIndex, "docs/a/index.md"));
3038 }
3039
3040 #[test]
3042 fn 子索引の追加は直近の親の索引の末尾にだけ行われる() {
3043 let files = [
3044 "docs/index.md",
3045 "docs/a/index.md",
3046 "docs/a/b/index.md",
3047 "docs/a/x.md",
3048 ];
3049 let tree = tree_of(&files);
3050 let repo = RepoFiles::new(files.iter().copied());
3051 let child = vec!["docs/a/b/index.md".to_string()];
3052 let metas = [
3053 meta("docs/a/index.md", "A", Some("DA")),
3054 meta("docs/a/b/index.md", "B", Some("DB")),
3055 ];
3056 let out = update_index(
3057 "- [x](x.md)\n",
3058 "docs/a/index.md",
3059 &tree,
3060 &[],
3061 &metas,
3062 &repo,
3063 &child,
3064 &test_excluded(),
3065 );
3066 assert_eq!(out, "- [x](x.md)\n- [B](b/) — DB\n");
3067 let root = update_index(
3068 "- [A](a/) — DA\n",
3069 "docs/index.md",
3070 &tree,
3071 &[],
3072 &metas,
3073 &repo,
3074 &[],
3075 &test_excluded(),
3076 );
3077 assert_eq!(root, "- [A](a/) — DA\n");
3078 }
3079}