Skip to main content

llmwiki_tooling/cmd/
agent.rs

1use std::collections::HashMap;
2
3use crate::config::IgnoreConfig;
4use crate::error::WikiError;
5use crate::frontmatter;
6use crate::parse;
7use crate::walk::{is_markdown_file, wiki_walk_builder};
8use crate::wiki::WikiRoot;
9
10use super::{DirStats, detect_mirror_candidates};
11
12/// Output the setup workflow prompt for an LLM agent.
13pub fn setup(root: &WikiRoot) -> Result<(), WikiError> {
14    let has_config = root.path().join("wiki.toml").is_file();
15    let version = env!("CARGO_PKG_VERSION");
16
17    print!(
18        r#"## Wiki Tool Setup (v{version})
19
20You are configuring a wiki for use with the `wiki` CLI tool.
21
22### Discover available commands
23
24Run `wiki --help` to see all top-level commands.
25Run `wiki <command> --help` for subcommand details (e.g., `wiki links --help`).
26
27"#
28    );
29
30    if has_config {
31        print!(
32            r#"### wiki.toml already exists
33
34A wiki.toml is present at the wiki root. Skip to validation.
35
361. Read the existing wiki.toml to understand the current configuration.
372. Run `wiki scan` to see the actual wiki structure and check for mismatches.
383. Run `wiki lint` and iterate:
39   - Real content problem -> fix the wiki content
40   - Config too strict or wrong scope -> adjust wiki.toml
41   - Uncertain -> ask the user
424. Run `wiki links check` to verify auto-linking candidates look right.
435. Once everything is clean, proceed to Step 6 (automated linting) and Step 7 (persist).
44
45"#
46        );
47    } else {
48        print!(
49            r#"### Step 1: Scan the wiki structure
50
51Run: `wiki scan`
52
53This outputs per-directory statistics: file counts, frontmatter field coverage,
54common section headings, and detected mirror candidates.
55
56### Step 2: Learn the config schema
57
58Run: `wiki setup example-config`
59
60This outputs a complete wiki.toml with every option, annotated with comments.
61Study it to understand what's available.
62
63### Step 3: Generate and customize wiki.toml
64
65Run: `wiki setup init`
66
67This generates a starting-point wiki.toml. Edit it to customize:
68- Set `autolink = false` on directories whose page names are too long or specific
69  to be useful auto-link patterns (dates, identifiers, compound slugs)
70- Add `[[rules]]` for required sections, required frontmatter, mirror parity
71- Add citation patterns if the wiki tracks references to external sources
72- Adjust `[checks]` severities if needed
73
74### Step 4: Validate iteratively
75
76Run: `wiki lint`
77
78For each finding:
79- Real content problem -> fix the wiki content
80- Config too strict or wrong scope -> adjust wiki.toml
81- Uncertain -> ask the user
82
83Use `wiki lint --severity error` to focus on blocking issues first.
84Use `wiki lint --severity warn` to review advisories separately.
85
86Repeat until `wiki lint` exits clean.
87
88### Step 5: Verify commands
89
90Run and verify output makes sense:
91- `wiki links check` — bare mentions should be genuine misses, not false positives
92- `wiki links broken` — should be empty if the wiki is healthy
93- `wiki refs to <pick a page from the wiki>` — verify the link graph looks right
94- Review `wiki scan` output for inconsistent section headings across directories
95  and use `wiki sections rename` to standardize them
96
97"#
98        );
99    }
100
101    print!(
102        r#"### Step 6: Set up automated linting
103
104Configure `wiki lint` to run automatically before commits. Options:
105- Git pre-commit hook (`.githooks/pre-commit` or `.git/hooks/pre-commit`)
106- Agent hook (e.g., Claude Code `pre-commit` hook in `.claude/settings.json`)
107- Both, if the wiki is edited by agents and humans
108
109Choose what fits this project's setup.
110
111### Step 7: Update project documentation
112
113Check if project documentation (CLAUDE.md, AGENTS.md, .cursorrules, or equivalent)
114already references wiki tooling commands.
115
116If it does:
117- Update command references to match the current CLI (`wiki --help`)
118- Remove references to commands that no longer exist
119- Verify workflow instructions use the correct command names and flags
120
121If it doesn't:
122- Add a tooling section documenting the key commands and when to use them
123- Integrate commands into existing workflow documentation where relevant
124  (e.g., "run `wiki links fix --write` after ingest" in an ingest workflow)
125
126Key commands the documentation should cover:
127- `wiki lint` — structural integrity check (before commits)
128- `wiki links check` / `wiki links fix --write` — bare mention detection (after page creation)
129- `wiki rename <old> <new> --write` — page rename with reference update
130- `wiki refs to <page>` — impact analysis before editing
131- `wiki sections rename <old> <new> --write` — heading standardization
132- `wiki setup prompt` — re-read these instructions
133"#
134    );
135
136    Ok(())
137}
138
139/// Scan wiki structure and output per-directory statistics.
140pub fn scan(root: &WikiRoot, ignore: &IgnoreConfig) -> Result<(), WikiError> {
141    let wiki_root = root.path();
142
143    // Find all directories containing .md files
144    let mut dir_stats: HashMap<String, DirStats> = HashMap::new();
145
146    for entry in wiki_walk_builder(wiki_root, wiki_root, ignore)?.build() {
147        let entry = entry.map_err(|e| WikiError::Walk {
148            path: wiki_root.to_path_buf(),
149            source: e,
150        })?;
151        let path = entry.path();
152        if !is_markdown_file(path) {
153            continue;
154        }
155
156        let rel_path = path.strip_prefix(wiki_root).unwrap_or(path);
157        let dir = rel_path
158            .parent()
159            .and_then(|p| p.to_str())
160            .unwrap_or(".")
161            .to_owned();
162
163        let stats = dir_stats.entry(dir).or_default();
164        stats.file_count += 1;
165
166        let source = std::fs::read_to_string(path).map_err(|e| WikiError::ReadFile {
167            path: path.to_path_buf(),
168            source: e,
169        })?;
170
171        // Frontmatter analysis
172        if let Ok(Some(fm)) = frontmatter::parse_frontmatter(&source)
173            && let serde_yml::Value::Mapping(map) = fm.data()
174        {
175            for key in map.keys() {
176                *stats.frontmatter_fields.entry(key.to_owned()).or_insert(0) += 1;
177            }
178        }
179
180        // Section heading analysis (## level)
181        let headings = parse::extract_headings(&source);
182        for h in &headings {
183            if h.level == 2 {
184                *stats.section_headings.entry(h.text.clone()).or_insert(0) += 1;
185            }
186        }
187    }
188
189    // Sort directories for consistent output
190    let mut dirs: Vec<_> = dir_stats.into_iter().collect();
191    dirs.sort_by(|a, b| a.0.cmp(&b.0));
192
193    for (dir, stats) in &dirs {
194        let display_dir = if dir.is_empty() { "." } else { dir.as_str() };
195        println!(
196            "## Directory: {display_dir}/ ({} files)\n",
197            stats.file_count
198        );
199
200        if !stats.frontmatter_fields.is_empty() {
201            println!("Frontmatter fields:");
202            let mut fields: Vec<_> = stats.frontmatter_fields.iter().collect();
203            fields.sort_by(|a, b| b.1.cmp(a.1));
204            for (field, count) in &fields {
205                let pct = **count as f64 / stats.file_count as f64 * 100.0;
206                println!("  {field:20} {count}/{} ({pct:.0}%)", stats.file_count);
207            }
208        } else {
209            println!("  No frontmatter detected.");
210        }
211
212        if !stats.section_headings.is_empty() {
213            println!("\nSection headings (## level):");
214            let mut headings: Vec<_> = stats.section_headings.iter().collect();
215            headings.sort_by(|a, b| b.1.cmp(a.1));
216            for (heading, count) in headings.iter().take(10) {
217                let pct = **count as f64 / stats.file_count as f64 * 100.0;
218                println!(
219                    "  \"{heading:18}\" {count}/{} ({pct:.0}%)",
220                    stats.file_count
221                );
222            }
223            if headings.len() > 10 {
224                println!("  ... and {} more", headings.len() - 10);
225            }
226        }
227
228        println!();
229    }
230
231    // Detect mirror candidates: directories with matching file stems
232    let dir_counts: Vec<(String, usize)> = dirs
233        .iter()
234        .map(|(dir, stats)| (dir.clone(), stats.file_count))
235        .collect();
236    let mirror_candidates = detect_mirror_candidates(&dir_counts);
237    if !mirror_candidates.is_empty() {
238        println!("## Mirror candidates\n");
239        for (a, b, count) in &mirror_candidates {
240            println!("  {a}/ ({count} files) <-> {b}/ ({count} files)");
241        }
242        println!();
243    }
244
245    // Check for index file
246    for candidate in &["index.md", "README.md", "_index.md"] {
247        let path = wiki_root.join(candidate);
248        if path.is_file() {
249            let source = std::fs::read_to_string(&path).map_err(|e| WikiError::ReadFile {
250                path: path.clone(),
251                source: e,
252            })?;
253            let wikilinks = parse::extract_wikilinks(&source);
254            let unique_refs: std::collections::HashSet<&str> =
255                wikilinks.iter().map(|wl| wl.page.as_str()).collect();
256            println!(
257                "## Index: {candidate}\n  References {} unique page names via wikilinks\n",
258                unique_refs.len()
259            );
260            break;
261        }
262    }
263
264    Ok(())
265}
266
267/// Output a complete annotated wiki.toml with all options.
268pub fn example_config() {
269    let sections = [
270        (
271            "# wiki.toml — Complete configuration reference\n\
272             #\n\
273             # Every available option with explanatory comments.\n\
274             # In practice, only include settings that differ from defaults.\n",
275            build_index_section(),
276        ),
277        (
278            "# Declare which directories contain wiki pages. Each entry is recursive\n\
279             # (includes all subdirectories).\n\
280             #\n\
281             # When multiple entries overlap (parent + child), the most-specific path wins\n\
282             # for per-page settings. This is the intended override mechanism:\n\
283             #   path = \"wiki\"          (parent, sets defaults for all of wiki/)\n\
284             #   path = \"wiki/papers\"   (child, overrides settings for wiki/papers/)\n\
285             #\n\
286             # If no [[directories]] are declared:\n\
287             #   Defaults to \"wiki/\" with autolink = true.\n\
288             #\n\
289             # If ANY [[directories]] are declared, the default is replaced entirely.\n",
290            build_directories_section(),
291        ),
292        ("", build_ignore_section()),
293        ("", build_linking_section()),
294        (
295            "# Wiki-wide structural checks. These apply to all pages regardless of directory.\n\
296             # Values: \"error\" (causes exit code 2), \"warn\" (prints but exits 0), \"off\"\n",
297            build_checks_section(),
298        ),
299        (
300            "# Parameterized rules scoped to specific directories. Each rule has a `check`\n\
301             # type and a `severity` (\"error\", \"warn\", or \"off\").\n\
302             #\n\
303             # The `dirs` field uses path-prefix matching:\n\
304             #   dirs = [\"wiki\"] matches any page under wiki/ (including subdirectories)\n\
305             #   dirs = [\"wiki/concepts\"] matches only pages under wiki/concepts/\n",
306            build_rules_section(),
307        ),
308    ];
309
310    for (comment, toml) in &sections {
311        if !comment.is_empty() {
312            println!("{comment}");
313        }
314        print!("{toml}");
315    }
316}
317
318fn toml_array(items: &[&str]) -> toml::Value {
319    toml::Value::Array(
320        items
321            .iter()
322            .map(|s| toml::Value::String(s.to_string()))
323            .collect(),
324    )
325}
326
327fn build_index_section() -> String {
328    let mut tbl = toml::Table::new();
329    tbl.insert(
330        "index".to_owned(),
331        toml::Value::String("index.md".to_owned()),
332    );
333
334    let mut out = String::new();
335    out.push_str("# Index file path, relative to wiki root.\n");
336    out.push_str(
337        "# Scanned for wikilinks (index-coverage check) but NOT treated as a wiki page.\n",
338    );
339    out.push_str("# Default: \"index.md\". Set to \"\" to disable index coverage.\n");
340    out.push_str(&toml::to_string_pretty(&tbl).unwrap());
341    out
342}
343
344fn build_directories_section() -> String {
345    let dirs = vec![
346        (
347            "wiki",
348            true,
349            "# autolink: pages here feed bare-mention auto-linking.\n# When true, filename stems become patterns for `wiki links check`.\n# Default: true\n",
350        ),
351        (
352            "wiki/papers",
353            false,
354            "# Long, specific names are poor auto-link patterns — disable.\n",
355        ),
356        ("wiki/topics", false, ""),
357    ];
358
359    let mut out = String::new();
360    for (path, autolink, comment) in dirs {
361        if !comment.is_empty() {
362            out.push_str(comment);
363        }
364        out.push_str("[[directories]]\n");
365        out.push_str(&format!("path = \"{path}\"\n"));
366        out.push_str(&format!("autolink = {autolink}\n\n"));
367    }
368    out
369}
370
371fn build_ignore_section() -> String {
372    let mut out = String::new();
373    out.push_str("[ignore]\n");
374    out.push_str("# Built-in non-wiki tool directory patterns are enabled by default:\n");
375    out.push_str("# .agents/, .claude/, .cursor/, .windsurf/, .pi/, etc.\n");
376    out.push_str("# Cargo-style: set default_patterns = false to discard them all.\n");
377    out.push_str("# Default: true\n");
378    out.push_str("default_patterns = true\n\n");
379    out.push_str("# Extra gitignore-style patterns, relative to the wiki root.\n");
380    out.push_str("# Additive with defaults; cannot subtract individual default patterns.\n");
381    out.push_str("# Default: []\n");
382    out.push_str("patterns = [\"generated/\", \"scratch/**/*.md\"]\n\n");
383    out
384}
385
386fn build_linking_section() -> String {
387    let mut out = String::new();
388    out.push_str("[linking]\n");
389
390    out.push_str("# Page names to never auto-link, even in autolink=true directories.\n");
391    out.push_str("# Default: []\n");
392    let exclude = toml::Value::Array(vec![
393        toml::Value::String("the".to_owned()),
394        toml::Value::String("a".to_owned()),
395        toml::Value::String("an".to_owned()),
396    ]);
397    out.push_str(&format!("exclude = {exclude}\n\n"));
398
399    out.push_str("# Frontmatter field that pages can set to false to opt out of auto-linking.\n");
400    out.push_str("# Default: \"autolink\"\n");
401    out.push_str("autolink_field = \"autolink\"\n\n");
402
403    out
404}
405
406fn build_checks_section() -> String {
407    let mut out = String::new();
408    out.push_str("[checks]\n");
409
410    out.push_str("# Every [[wikilink]] must resolve to an existing page.\n");
411    out.push_str("# Fragment references ([[page#heading]], [[page#^block]]) are also validated.\n");
412    out.push_str("# Default: \"error\"\n");
413    out.push_str("broken_links = \"error\"\n\n");
414
415    out.push_str(
416        "# Every wiki page must have at least one inbound [[wikilink]] from another page.\n",
417    );
418    out.push_str("# Default: \"error\"\n");
419    out.push_str("orphan_pages = \"error\"\n\n");
420
421    out.push_str("# Every wiki page must be referenced via [[wikilink]] in the index file.\n");
422    out.push_str("# Only active if `index` is set and the file exists.\n");
423    out.push_str("# Default: \"error\"\n");
424    out.push_str("index_coverage = \"error\"\n\n");
425
426    out
427}
428
429fn build_rules_section() -> String {
430    let mut out = String::new();
431
432    // Required sections
433    out.push_str("# --- Required sections ---\n");
434    out.push_str("# Pages in the specified directories must contain these ## headings.\n\n");
435
436    out.push_str("[[rules]]\ncheck = \"required-sections\"\n");
437    out.push_str(&format!("dirs = {}\n", toml_array(&["wiki/concepts"])));
438    out.push_str(&format!(
439        "sections = {}\n",
440        toml_array(&["See also", "Viability check"])
441    ));
442    out.push_str("severity = \"error\"\n\n");
443
444    out.push_str("[[rules]]\ncheck = \"required-sections\"\n");
445    out.push_str(&format!("dirs = {}\n", toml_array(&["wiki/topics"])));
446    out.push_str(&format!("sections = {}\n", toml_array(&["See also"])));
447    out.push_str("severity = \"warn\"\n\n");
448
449    // Required frontmatter
450    out.push_str("# --- Required frontmatter fields ---\n");
451    out.push_str(
452        "# Pages in the specified directories must have these YAML frontmatter fields.\n\n",
453    );
454
455    out.push_str("[[rules]]\ncheck = \"required-frontmatter\"\n");
456    out.push_str(&format!(
457        "dirs = {}\n",
458        toml_array(&["wiki/concepts", "wiki/topics"])
459    ));
460    out.push_str(&format!(
461        "fields = {}\n",
462        toml_array(&["title", "tags", "date"])
463    ));
464    out.push_str("severity = \"error\"\n\n");
465
466    out.push_str("[[rules]]\ncheck = \"required-frontmatter\"\n");
467    out.push_str(&format!("dirs = {}\n", toml_array(&["wiki/papers"])));
468    out.push_str(&format!(
469        "fields = {}\n",
470        toml_array(&["title", "tags", "date", "sources"])
471    ));
472    out.push_str("severity = \"error\"\n\n");
473
474    // Mirror parity
475    out.push_str("# --- Mirror parity ---\n");
476    out.push_str(
477        "# Two directories must contain matching filenames (by stem, ignoring extension).\n",
478    );
479    out.push_str("# Useful for raw-source / processed-page pairs.\n");
480    out.push_str("# Note: `right` does NOT need to be a declared [[directories]] entry.\n\n");
481
482    out.push_str("[[rules]]\ncheck = \"mirror-parity\"\n");
483    out.push_str("left = \"wiki/papers\"\nright = \"raw/papers\"\n");
484    out.push_str("severity = \"error\"\n\n");
485
486    // Citation patterns
487    out.push_str("# --- Citation patterns ---\n");
488    out.push_str("# Detect references in prose that should have corresponding wiki pages.\n");
489    out.push_str("#\n");
490    out.push_str("# Each pattern has a regex with a named capture group `id`.\n");
491    out.push_str("# `match_in`: which directory to search for matching pages.\n");
492    out.push_str("# `match_mode`:\n");
493    out.push_str(
494        "#   \"content\"  - search page file contents for the captured ID string (default)\n",
495    );
496    out.push_str("#   \"filename\" - check if a page with the captured ID as filename exists\n");
497    out.push_str("#\n");
498    out.push_str("# Use `preset` instead of `pattern` for built-in patterns:\n");
499    out.push_str(
500        "#   \"bold-method-year\" - matches **MethodName** (Author, YEAR), checks filenames\n\n",
501    );
502
503    out.push_str("[[rules]]\ncheck = \"citation-pattern\"\nname = \"arxiv\"\n");
504    out.push_str(&format!(
505        "dirs = {}\n",
506        toml_array(&["wiki/concepts", "wiki/topics"])
507    ));
508    out.push_str("pattern = 'arxiv\\.org/abs/(?P<id>\\d{4}\\.\\d{4,5})'\n");
509    out.push_str("match_in = \"wiki/papers\"\nmatch_mode = \"content\"\nseverity = \"warn\"\n\n");
510
511    out.push_str("# Preset-based: no regex needed, preset bundles pattern + match_mode.\n");
512    out.push_str("[[rules]]\ncheck = \"citation-pattern\"\nname = \"bold-method\"\n");
513    out.push_str("preset = \"bold-method-year\"\n");
514    out.push_str(&format!(
515        "dirs = {}\n",
516        toml_array(&["wiki/concepts", "wiki/topics"])
517    ));
518    out.push_str("match_in = \"wiki/papers\"\nseverity = \"warn\"\n\n");
519
520    out.push_str("[[rules]]\ncheck = \"citation-pattern\"\nname = \"doi\"\n");
521    out.push_str(&format!("dirs = {}\n", toml_array(&["wiki"])));
522    out.push_str("pattern = 'doi\\.org/(?P<id>10\\.\\d{4,}/[^\\s)]+)'\n");
523    out.push_str("match_in = \"wiki/papers\"\nmatch_mode = \"content\"\nseverity = \"warn\"\n");
524
525    out
526}