Skip to main content

llmwiki_tooling/cmd/
init.rs

1use 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
11/// Generate a minimal wiki.toml from detected wiki structure.
12pub 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    // Only emit index if it's not the default "index.md"
26    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    // Detect directories — only emit if structure differs from auto-detection default
38    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    // Auto-detection default is "wiki" if wiki/ exists, "." otherwise.
50    // Only emit [[directories]] entries that change a setting (e.g. autolink = false).
51    // For init, we don't know which dirs should be autolink=false, so omit all.
52    // The agent will add overrides after reviewing `wiki scan` output.
53
54    // Scan directories for rules to generate
55    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        // Required frontmatter: fields present in 100% of files
68        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        // Required sections: ## headings present in 100% of files
91        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    // Mirror parity: find directory pairs with matching file counts and shared name component
115    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                // Only suggest mirrors between content and non-content dirs
124                && (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
219/// Scan all directories under root for file counts (including non-wiki dirs like raw/).
220fn 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}