llmwiki_tooling/cmd/
init.rs1use 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
13pub 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 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 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 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 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 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 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 && (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
216fn 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}