Skip to main content

miwa/
index.rs

1//! Index (`index.md`) structure checks and the `miwa index` command.
2
3use 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/// One list entry of an index: a list item that starts with a link.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct IndexEntry {
15    /// Source text of the link text (between `[` and the matching `]`).
16    pub link_text: String,
17    /// Link destination as interpreted by pulldown-cmark (without `<...>` and
18    /// the title attribute). Used for comparison and resolution only; never
19    /// written back.
20    pub dest: String,
21    /// Summary with the separator and surrounding whitespace removed.
22    pub description: String,
23    /// One of `-` `–` `—` `:`, if present.
24    pub separator: Option<char>,
25    /// From just after `[` to just before the matching `]`.
26    pub text_span: Range<usize>,
27    /// From the end of the link to the end of the inline content (separator +
28    /// summary, may span lines).
29    pub tail_span: Range<usize>,
30    /// From the start of the line containing `text_span.start` through the line
31    /// terminator (`\n` or `\r\n`) after `tail_span.end`.
32    pub line_span: Range<usize>,
33}
34
35/// How an entry's link destination resolves.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum Resolution {
38    /// External URL / URI scheme: ignored.
39    External,
40    /// Cannot be resolved (empty, absolute, escapes repo, outside root, self or
41    /// ancestor directory); the reason is used as the finding detail.
42    Unresolvable(&'static str),
43    /// Repo-relative file path (may not exist).
44    File(String),
45    /// Repo-relative directory whose `index.md` is the target.
46    Directory(String),
47}
48
49/// A typed document (has frontmatter `type`) under an index root.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct Document {
52    pub path: String,
53    pub title: String,
54    pub description: Option<String>,
55}
56
57/// An `index.md` with the title and summary it contributes as a directory
58/// (FR-071): frontmatter `title` (else the directory name) and `description`.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct IndexMeta {
61    pub path: String,
62    pub title: String,
63    pub description: Option<String>,
64    /// The frontmatter could not be parsed; the title and summary are then
65    /// unknown and neither checked nor rewritten (reported as `invalid`).
66    pub invalid: bool,
67}
68
69/// An index structure problem. `target` follows the canonical form: for
70/// `BrokenEntry` and for `TitleMismatch` / `DescriptionMismatch` of a
71/// directory entry it is `"<index path> -> <dest>"`, otherwise (including
72/// `MissingDirectoryDescription`) the path.
73#[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/// File body and parsed frontmatter, read once by `build_graph`.
81#[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
160/// Extract index entries from `text`.
161///
162/// Parses with `Parser::new_ext(text, ENABLE_TASKLISTS).into_offset_iter()`. A
163/// list item (any nesting, ordered or unordered) is an entry iff its first
164/// inline event (skipping `Start(Paragraph)`) is `Start(Link)`. Task-list
165/// markers, bold, plain text etc. are non-entries. A leading BOM is ignored for
166/// parsing, and spans are offsets into `text` including the BOM. `text_span` covers all ranged events inside the
167/// link (empty link: `start+1..start+1`); `tail_span` covers inline events after
168/// `End(Link)` up to `End(Item)` / `End(Paragraph)` / `Start(List)` (excluding
169/// soft/hard breaks). `description` is `tail_span` trimmed, minus one leading
170/// separator char, trim_start; `\[` `\]` are unescaped for comparison.
171pub 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        // pulldown-cmark's Text ranges omit the backslash of an escape, so the
206        // start is pinned to just after `[`, and the end is the first `]` at or
207        // after the end of the last inner event.
208        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        // The End(Link) range of a collapsed reference `[T][]` stops before `[]`.
214        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
258/// Resolve an entry destination relative to `index_path` within `root`.
259///
260/// Order: empty / `#...` -> `Unresolvable("empty target")`; `://` or URI scheme
261/// -> `External`; leading `/` -> `Unresolvable("absolute path")`; strip
262/// fragment, percent-decode, `markdown::normalize_relative` (escape ->
263/// `Unresolvable("escapes repository")`); outside `root` ->
264/// `Unresolvable("outside index root")`; trailing `/` or file name `index.md`
265/// -> directory candidate (`index.md` -> its parent), and a candidate that is
266/// the index's own directory or an ancestor -> `Unresolvable("self or ancestor
267/// directory")`, else `Directory`; otherwise `File` if present in `file_set`,
268/// or `Directory` if some file lies under `path/` (`repo.dirs`), else `File`
269/// (missing).
270pub 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
313/// Repo files plus every ancestor directory of them, for O(1) lookups.
314pub 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
333/// Ownership and parent/child relations among the indexes of one root.
334pub 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    /// Nearest index at or above `dir`, never above the root (the root index
352    /// even when it does not exist yet).
353    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    /// The index responsible for listing the document at `doc_path`.
368    pub fn owner_of(&self, doc_path: &str) -> String {
369        self.nearest(parent_dir(doc_path))
370    }
371
372    /// The index that should link to `index_path`; `None` for the root index.
373    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    /// Indexes whose parent is `index_path`.
383    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/// Result of one index processed by [`run`].
393#[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/// The `.md` files under an index root, classified.
411#[derive(Debug, Default)]
412pub struct RootFiles {
413    pub documents: Vec<Document>,
414    pub untyped: Vec<String>,
415    pub indexes: Vec<IndexMeta>,
416}
417
418/// Classify the `.md` files under `root` into documents, untyped files and
419/// indexes.
420///
421/// `index.md` -> indexes; `log.md` -> ignored; frontmatter error -> ignored
422/// (already a ParseError); no frontmatter or no `type` -> untyped; otherwise a
423/// `Document`.
424pub 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
475/// Frontmatter `title`: trimmed, non-empty, single line.
476fn 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
484/// Frontmatter `description`: trimmed, non-empty, single line (no LF or CR).
485pub 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
493/// Title of a document: frontmatter `title` (trimmed, non-empty, single line)
494/// -> first H1 of the body (source text of the inner events) -> file name
495/// without `.md`.
496pub 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                // Only the first level-1 heading counts; an empty one falls
515                // back to the file name.
516                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
537/// `<index path> -> <dest>`, the finding target of an entry.
538fn 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
550/// Run all index checks for every root in `config.index`.
551///
552/// Per root: `UntypedFile` for untyped files and `MissingDescription` for
553/// documents without a description; `MissingIndex` (target = root) if there is
554/// no `root/index.md` and at least one document (then the index-side checks are
555/// skipped); each document / non-root index is owned by the nearest ancestor
556/// index; each entry is resolved: `External` ignored, `Unresolvable` ->
557/// `BrokenEntry`, `File` not a document -> `BrokenEntry`, title mismatch ->
558/// `TitleMismatch`, description mismatch -> `DescriptionMismatch`; `Directory`
559/// without `index.md` -> `BrokenEntry`, otherwise compared with the child
560/// index's title / description (`TitleMismatch` / `DescriptionMismatch`; no
561/// description -> `MissingDirectoryDescription` targeting the child index); unlisted documents -> `NotListed`; child
562/// indexes not reached from the parent -> `UnlinkedIndex`. Duplicates are
563/// removed by the set.
564pub 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/// Return the updated text of an index.
765///
766/// Only `text_span` and `tail_span` are rewritten; link destinations are never
767/// touched. `documents` are all documents of the root; ownership is derived
768/// from `tree`. For `File(p)` entries that are
769/// documents: `text_span` becomes the escaped title (`[` `]` -> `\[` `\]`;
770/// not for collapsed / shortcut references, whose link text doubles as the
771/// reference label), `tail_span` becomes ` {sep} {desc}` (sep = existing or
772/// `—`; empty if no description); directory entries (the link target is a
773/// directory with a child `index.md` in `indexes`) are rewritten the same way
774/// from the child's title / description. Entries whose `File(p)` does not exist
775/// (`exists` false) and is not under a default-excluded directory have their
776/// `line_span` removed. Then missing owned documents (path order) and unlinked
777/// child indexes (path order, `- [<title>](<dir>/) — <summary>`, no separator
778/// without a summary) are appended as list items, with percent-encoded
779/// destinations, matching the text's line ending, and a blank line inserted
780/// when the last non-empty line is not a list item.
781#[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        // Collapsed `[T][]` and shortcut `[T]` references use the link text as
825        // the reference label, so it must not change.
826        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
943/// `miwa index`: create or update the indexes of every root in `config.index`.
944///
945/// Errors if `config.index` is empty ("index is not configured in
946/// .miwa/config.yaml"). Creates a missing root index as a flat list, otherwise
947/// updates the root index and its descendants. Each file is written to a
948/// temporary file in the same directory and renamed into place. Returns the
949/// status of every processed index, in processing order; nothing is printed.
950pub 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    // ---- helpers -------------------------------------------------------
1052
1053    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    /// A typed document source with `title` / `description` front matter.
1066    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    /// `update_index` for `docs/index.md` (root `docs`); every file in `files`
1128    /// exists on disk.
1129    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    /// An `index.md` source with `title` / `description` front matter and one
1143    /// entry body line.
1144    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    /// Like `upd`, with the child index metadata supplied.
1157    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    /// Index tree of root `docs` from the `index.md` files among `files`.
1185    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    // ---- FR-068 parse_entries -----------------------------------------
1196
1197    // [FR-068]
1198    #[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    // [FR-068]
1205    #[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    // [FR-068]
1212    #[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    // [FR-068]
1224    #[test]
1225    fn 太字で包まれたリンクで始まる項目はエントリではない() {
1226        assert!(parse_entries("- **[a](a.md)** — x\n").is_empty());
1227    }
1228
1229    // [FR-068]
1230    #[test]
1231    fn タスクリストの項目はエントリではない() {
1232        assert!(parse_entries("- [ ] [a](a.md)\n- [x] [b](b.md)\n").is_empty());
1233    }
1234
1235    // [FR-068]
1236    #[test]
1237    fn リンクで始まらない項目はエントリではない() {
1238        assert!(parse_entries("- see [a](a.md)\n- 説明のみ\n").is_empty());
1239    }
1240
1241    // [FR-068]
1242    #[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    // [FR-068]
1249    #[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    // [FR-068]
1263    #[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    // [FR-068]
1273    #[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    // [FR-068]
1282    #[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    // [FR-068]
1292    #[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    // ---- FR-069 entry description -------------------------------------
1301
1302    // [FR-069]
1303    #[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    // [FR-069]
1314    #[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    // [FR-069]
1322    #[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    // [FR-069]
1333    #[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    // [FR-069]
1343    #[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    // [FR-069]
1352    #[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    // [FR-069]
1361    #[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    // [FR-069]
1370    #[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    // [FR-083]
1381    #[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    // [FR-069]
1391    #[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    // ---- FR-070 resolve -----------------------------------------------
1404
1405    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    // [FR-070]
1415    #[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    // [FR-070]
1428    #[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    // [FR-070]
1441    #[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    // [FR-070]
1458    #[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    // [FR-070]
1471    #[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    // [FR-070]
1488    #[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    // [FR-070]
1498    #[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    // [FR-070]
1507    #[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    // [FR-070]
1518    #[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    // ---- FR-071 documents ----------------------------------------------
1527
1528    // [FR-071]
1529    #[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    // [FR-071]
1536    #[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    // [FR-071]
1543    #[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    // [FR-071]
1550    #[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    // [FR-071]
1557    #[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    // [FR-071]
1564    #[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    // [FR-071]
1571    #[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    // [FR-071]
1582    #[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    // [FR-071]
1626    #[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    // [FR-067]
1677    #[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    // [FR-075]
1698    #[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    // [FR-071]
1717    #[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    // ---- FR-072〜079 collect_issues ------------------------------------
1729
1730    // [FR-072]
1731    #[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    // [FR-072]
1744    #[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    // [FR-072]
1759    #[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    // [FR-074]
1777    #[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    // [FR-072]
1796    #[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    // [FR-072]
1809    #[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    // [FR-072]
1826    #[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    // [FR-073]
1840    #[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    // [FR-073]
1863    #[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    // [FR-074]
1873    #[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    // [FR-075]
1890    #[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    // [FR-076]
1911    #[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    // [FR-077]
1961    #[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    // [FR-077]
1976    #[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    // [FR-077]
1992    #[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    // [FR-077]
2007    #[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    // [FR-078]
2026    #[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    // [FR-078]
2047    #[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    // [FR-079]
2059    #[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        // The entry's own summary is not compared when the child has none.
2078        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    // [FR-079]
2087    #[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    // [FR-079]
2110    #[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    // [FR-079]
2136    #[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    // [FR-079]
2155    #[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    // [FR-077]
2168    #[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    // [FR-077]
2189    #[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    // [FR-077]
2211    #[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    // [FR-079]
2234    #[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    // [FR-079]
2256    #[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    // ---- FR-081 update_index rewrite -----------------------------------
2272
2273    // [FR-081]
2274    #[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    // [FR-081]
2293    #[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    // [FR-081]
2303    #[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    // [FR-081]
2318    #[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        // タイトルがラベルと同じなら折り畳み・短縮形も壊れない
2327        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    // [FR-081]
2339    #[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    // [FR-081]
2349    #[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    // [FR-081]
2361    #[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    // [FR-081]
2376    #[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    // [FR-081]
2384    #[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    // ---- FR-082 append --------------------------------------------------
2394
2395    // [FR-082]
2396    #[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    // [FR-082]
2412    #[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    // [FR-082]
2423    #[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        // 箇条書きで終わり改行なし -> 改行のみ足す
2429        assert_eq!(
2430            upd("- [x](x.md)", &docs, &files, &[]),
2431            format!("- [x](x.md)\n{item}")
2432        );
2433        // 段落で終わる -> 空行を 1 つ挟む
2434        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        // 順序付き・別記号の項目で終わる -> 空行なし
2440        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        // 本文が空 -> そのまま
2443        assert_eq!(upd("", &docs, &files, &[]), item);
2444    }
2445
2446    // [FR-082]
2447    #[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    // [FR-082]
2461    #[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    // [FR-082]
2482    #[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    // [FR-081]
2499    #[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        // Destination source and reference forms stay as written.
2506        let out = upd_meta("- [old](<sub/> \"t\")\n", &[], &metas, &files, &[]);
2507        assert_eq!(out, "- [Sub](<sub/> \"t\") — DS\n");
2508        // Already matching: byte-identical.
2509        let same = "- [Sub](sub/) — DS\n";
2510        assert_eq!(upd_meta(same, &[], &metas, &files, &[]), same);
2511    }
2512
2513    // [FR-081]
2514    #[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    // [FR-081]
2528    #[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    // [FR-085]
2542    #[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    // ---- FR-083 removal --------------------------------------------------
2553
2554    // [FR-083]
2555    #[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    // [FR-083]
2566    #[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    // [FR-083]
2581    #[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    // [FR-083]
2596    #[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    // ---- FR-084 create ---------------------------------------------------
2610
2611    // [FR-084]
2612    #[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    // [FR-084]
2624    #[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    // ---- FR-085 preserve / idempotent -----------------------------------
2642
2643    // [FR-085]
2644    #[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    // [FR-085]
2655    #[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    // ---- boundary tests added for review ---------------------------------
2670
2671    // [FR-069]
2672    #[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    // [FR-070]
2682    #[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    // [FR-070]
2695    #[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    // [FR-071]
2704    #[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    // [FR-071]
2711    #[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    // [FR-073]
2720    #[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    // [FR-076]
2749    #[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    // [FR-077]
2775    #[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    // [FR-081]
2788    #[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    // [FR-081]
2798    #[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    // [FR-083]
2813    #[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    // [FR-083]
2828    #[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    // [FR-085]
2836    #[test]
2837    fn 書き込みはrename失敗時に同じディレクトリの一時ファイルを残さない() {
2838        let dir = tempfile::tempdir().unwrap();
2839        let target = dir.path().join("index.md");
2840        // rename 先がディレクトリなので失敗する。
2841        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    // ---- review fixes ----------------------------------------------------
2860
2861    // [FR-081]
2862    #[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    // [FR-081]
2872    #[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    // [FR-081]
2890    #[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    // [FR-082]
2905    #[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    // [FR-081]
2917    #[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    // [FR-083]
2929    #[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    // [FR-071]
2949    #[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    // [FR-071]
2957    #[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    // [FR-071]
2979    #[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        // Not appended as an unlinked child either.
2987        assert_eq!(upd_meta("", &[], &[m], &files, &["docs/sub/index.md"]), "");
2988    }
2989
2990    // [FR-071]
2991    #[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    // [FR-069]
2998    #[test]
2999    fn 強調を含むリンク文はソース文字列のまま扱われる() {
3000        let e = &parse_entries("- [*a*](x.md)\n")[0];
3001        assert_eq!(e.link_text, "*a*");
3002    }
3003
3004    // [FR-076]
3005    #[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    // [FR-079]
3026    #[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    // [FR-082]
3041    #[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}