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