Skip to main content

llmwiki_tooling/cmd/
init.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use crate::config::IgnoreConfig;
5use crate::error::WikiError;
6use crate::frontmatter;
7use crate::parse;
8use crate::walk::{is_markdown_file, wiki_walk_builder};
9use crate::wiki::WikiRoot;
10
11use super::{DirStats, share_name_component};
12
13/// Generate a minimal wiki.toml from detected wiki structure.
14pub fn init(root: &WikiRoot, force: bool, show: bool) -> Result<(), WikiError> {
15    let config_path = root.path().join("wiki.toml");
16
17    if !show && config_path.is_file() && !force {
18        eprintln!(
19            "wiki.toml already exists at {}. Use --force to overwrite.",
20            config_path.display()
21        );
22        return Ok(());
23    }
24
25    let mut lines = Vec::new();
26
27    // Only emit index if it's not the default "index.md"
28    let detected_index = ["index.md", "README.md"]
29        .iter()
30        .find(|c| root.path().join(c).is_file())
31        .copied();
32    if let Some(idx) = detected_index
33        && idx != "index.md"
34    {
35        lines.push(format!("index = \"{idx}\""));
36        lines.push(String::new());
37    }
38
39    // Detect directories — only emit if structure differs from auto-detection default
40    let wiki_dir = root.path().join("wiki");
41    let content_dirs: Vec<String> = if wiki_dir.is_dir() {
42        let mut dirs = vec!["wiki".to_owned()];
43        for subdir in list_subdirs(&wiki_dir) {
44            dirs.push(format!("wiki/{subdir}"));
45        }
46        dirs
47    } else {
48        vec![".".to_owned()]
49    };
50
51    // Auto-detection default is "wiki" if wiki/ exists, "." otherwise.
52    // Only emit [[directories]] entries that change a setting (e.g. autolink = false).
53    // For init, we don't know which dirs should be autolink=false, so omit all.
54    // The agent will add overrides after reviewing `wiki scan` output.
55
56    // Scan directories for rules to generate
57    let mut rules_lines = Vec::new();
58
59    for dir in &content_dirs {
60        let abs_dir = root.path().join(dir);
61        if !abs_dir.is_dir() {
62            continue;
63        }
64        let stats = scan_dir(&abs_dir)?;
65        if stats.file_count == 0 {
66            continue;
67        }
68
69        // Required frontmatter: fields present in 100% of files
70        let mut required_fm: Vec<&str> = stats
71            .frontmatter_fields
72            .iter()
73            .filter(|(_, count)| **count == stats.file_count)
74            .map(|(name, _)| name.as_str())
75            .collect();
76        required_fm.sort();
77
78        if !required_fm.is_empty() {
79            let fields_str = required_fm
80                .iter()
81                .map(|f| format!("\"{f}\""))
82                .collect::<Vec<_>>()
83                .join(", ");
84            rules_lines.push("[[rules]]".to_owned());
85            rules_lines.push("check = \"required-frontmatter\"".to_owned());
86            rules_lines.push(format!("dirs = [\"{dir}\"]"));
87            rules_lines.push(format!("fields = [{fields_str}]"));
88            rules_lines.push("severity = \"error\"".to_owned());
89            rules_lines.push(String::new());
90        }
91
92        // Required sections: ## headings present in 100% of files
93        let mut required_sections: Vec<&str> = stats
94            .section_headings
95            .iter()
96            .filter(|(_, count)| **count == stats.file_count)
97            .map(|(name, _)| name.as_str())
98            .collect();
99        required_sections.sort();
100
101        if !required_sections.is_empty() {
102            let sections_str = required_sections
103                .iter()
104                .map(|s| format!("\"{s}\""))
105                .collect::<Vec<_>>()
106                .join(", ");
107            rules_lines.push("[[rules]]".to_owned());
108            rules_lines.push("check = \"required-sections\"".to_owned());
109            rules_lines.push(format!("dirs = [\"{dir}\"]"));
110            rules_lines.push(format!("sections = [{sections_str}]"));
111            rules_lines.push("severity = \"error\"".to_owned());
112            rules_lines.push(String::new());
113        }
114    }
115
116    // Mirror parity: find directory pairs with matching file counts and shared name component
117    let dir_counts = scan_all_dir_counts(root)?;
118    for i in 0..dir_counts.len() {
119        for j in (i + 1)..dir_counts.len() {
120            let (dir_a, count_a) = &dir_counts[i];
121            let (dir_b, count_b) = &dir_counts[j];
122            if count_a == count_b
123                && *count_a > 0
124                && share_name_component(dir_a, dir_b)
125                // Only suggest mirrors between content and non-content dirs
126                && (content_dirs.contains(dir_a) != content_dirs.contains(dir_b)
127                    || !content_dirs.contains(dir_a))
128            {
129                let (left, right) = if content_dirs.contains(dir_a) {
130                    (dir_a.as_str(), dir_b.as_str())
131                } else {
132                    (dir_b.as_str(), dir_a.as_str())
133                };
134                rules_lines.push("[[rules]]".to_owned());
135                rules_lines.push("check = \"mirror-parity\"".to_owned());
136                rules_lines.push(format!("left = \"{left}\""));
137                rules_lines.push(format!("right = \"{right}\""));
138                rules_lines.push("severity = \"error\"".to_owned());
139                rules_lines.push(String::new());
140            }
141        }
142    }
143
144    lines.extend(rules_lines);
145
146    let content = lines.join("\n") + "\n";
147
148    if show {
149        print!("{content}");
150    } else {
151        std::fs::write(&config_path, &content).map_err(|e| WikiError::WriteFile {
152            path: config_path.clone(),
153            source: e,
154        })?;
155        println!("created {}", config_path.display());
156    }
157
158    Ok(())
159}
160
161fn scan_dir(dir: &Path) -> Result<DirStats, WikiError> {
162    let mut stats = DirStats::default();
163
164    let entries = std::fs::read_dir(dir).map_err(|e| WikiError::ReadFile {
165        path: dir.to_path_buf(),
166        source: e,
167    })?;
168
169    for entry in entries.flatten() {
170        let path = entry.path();
171        if !is_markdown_file(&path) {
172            continue;
173        }
174        stats.file_count += 1;
175
176        let source = std::fs::read_to_string(&path).map_err(|e| WikiError::ReadFile {
177            path: path.clone(),
178            source: e,
179        })?;
180
181        if let Ok(Some(fm)) = frontmatter::parse_frontmatter(&source)
182            && let serde_yml::Value::Mapping(map) = fm.data()
183        {
184            for key in map.keys() {
185                *stats.frontmatter_fields.entry(key.to_owned()).or_insert(0) += 1;
186            }
187        }
188
189        for h in parse::extract_headings(&source) {
190            if h.level == 2 {
191                *stats.section_headings.entry(h.text).or_insert(0) += 1;
192            }
193        }
194    }
195
196    Ok(stats)
197}
198
199fn list_subdirs(dir: &Path) -> Vec<String> {
200    let mut subdirs = Vec::new();
201    if let Ok(entries) = std::fs::read_dir(dir) {
202        for entry in entries.flatten() {
203            let path = entry.path();
204            if path.is_dir()
205                && let Some(name) = path.file_name().and_then(|n| n.to_str())
206                && !name.starts_with('.')
207            {
208                subdirs.push(name.to_owned());
209            }
210        }
211    }
212    subdirs.sort();
213    subdirs
214}
215
216/// Scan all directories under root for file counts (including non-wiki dirs like raw/).
217fn scan_all_dir_counts(root: &WikiRoot) -> Result<Vec<(String, usize)>, WikiError> {
218    let mut counts: HashMap<String, usize> = HashMap::new();
219
220    let ignore = IgnoreConfig::default();
221    for entry in wiki_walk_builder(root.path(), root.path(), &ignore)?.build() {
222        let entry = entry.map_err(|e| WikiError::Walk {
223            path: root.path().to_path_buf(),
224            source: e,
225        })?;
226        let path = entry.path();
227        if is_markdown_file(path) {
228            let rel = path.strip_prefix(root.path()).unwrap_or(path);
229            if let Some(dir) = rel.parent().and_then(|p| p.to_str())
230                && !dir.is_empty()
231            {
232                *counts.entry(dir.to_owned()).or_insert(0) += 1;
233            }
234        }
235    }
236
237    let mut result: Vec<_> = counts.into_iter().collect();
238    result.sort_by(|a, b| a.0.cmp(&b.0));
239    Ok(result)
240}