1use std::path::Path;
2
3use anyhow::{Context, Result, bail};
4use serde::{Deserialize, Serialize};
5use toml;
6
7use crate::config::{GlobalConfig, WikiEntry, load_global, save_global};
8use crate::default_schemas::default_schemas;
9use crate::git;
10
11#[derive(Debug, Serialize, Deserialize)]
15pub struct CreateReport {
16 pub path: String,
18 pub name: String,
20 pub created: bool,
22 pub registered: bool,
24 pub committed: bool,
26}
27
28#[derive(Debug, Serialize, Deserialize)]
30pub struct RegisterReport {
31 pub path: String,
33 pub name: String,
35 pub registered: bool,
37 pub created: bool,
39 pub committed: bool,
41}
42
43pub fn create(
47 path: &Path,
48 name: &str,
49 description: Option<&str>,
50 force: bool,
51 set_default: bool,
52 config_path: &Path,
53 wiki_root: Option<&str>,
54) -> Result<CreateReport> {
55 let mut created = false;
56 if !path.exists() {
57 std::fs::create_dir_all(path)?;
58 created = true;
59 }
60 let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
61 let wiki_root = wiki_root.unwrap_or("wiki");
62 let mut committed = false;
63
64 let global = load_global(config_path)?;
66 if let Some(existing) = global
67 .wikis
68 .iter()
69 .find(|w| w.path == path.to_string_lossy())
70 {
71 if existing.name == name {
72 ensure_structure(&path, name, description, wiki_root)?;
73 return Ok(CreateReport {
74 path: path.to_string_lossy().into(),
75 name: name.into(),
76 created: false,
77 registered: false,
78 committed: false,
79 });
80 } else if !force {
81 bail!(
82 "wiki already registered as \"{}\". Use --force to rename.",
83 existing.name
84 );
85 }
86 }
87
88 ensure_structure(&path, name, description, wiki_root)?;
89
90 if !path.join(".git").exists() {
92 git::init_repo(&path)?;
93 }
94
95 let commit_result = git::commit(&path, &format!("create: {name}"));
97 if let Ok(ref hash) = commit_result
98 && !hash.is_empty()
99 {
100 committed = true;
101 }
102
103 let entry = WikiEntry {
105 name: name.into(),
106 path: path.to_string_lossy().into(),
107 description: description.map(|s| s.into()),
108 remote: None,
109 };
110 register(entry, force, config_path)?;
111
112 if let Some(wiki_dir) = config_path.parent() {
114 let logs_dir = wiki_dir.join("logs");
115 if !logs_dir.exists() {
116 std::fs::create_dir_all(&logs_dir)?;
117 }
118 }
119
120 if set_default {
121 set_default_wiki(name, config_path)?;
122 }
123
124 Ok(CreateReport {
125 path: path.to_string_lossy().into(),
126 name: name.into(),
127 created,
128 registered: true,
129 committed,
130 })
131}
132
133pub fn register_existing(
139 path: &Path,
140 name: &str,
141 description: Option<&str>,
142 wiki_root_override: Option<&str>,
143 config_path: &Path,
144) -> Result<RegisterReport> {
145 if !path.exists() {
146 bail!("path \"{}\" does not exist", path.display());
147 }
148 let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
149
150 let existing_toml_root: Option<String> = {
152 let toml_path = path.join("wiki.toml");
153 if toml_path.exists() {
154 let raw = std::fs::read_to_string(&toml_path)?;
155 if raw.contains("wiki_root") {
156 let cfg: crate::config::WikiConfig = toml::from_str(&raw).unwrap_or_default();
157 Some(cfg.wiki_root)
158 } else {
159 None
160 }
161 } else {
162 None
163 }
164 };
165
166 let effective_root: String = match (&wiki_root_override, &existing_toml_root) {
168 (Some(flag), Some(toml)) if *flag != toml => {
169 bail!(
170 "wiki.toml already declares wiki_root = \"{toml}\". \
171 Remove it manually before registering with a different value."
172 );
173 }
174 (Some(flag), _) => flag.to_string(),
175 (None, Some(toml)) => toml.clone(),
176 (None, None) => "wiki".to_string(),
177 };
178
179 validate_wiki_root(&path, &effective_root)?;
180
181 if wiki_root_override.is_some() && existing_toml_root.is_none() {
183 let toml_path = path.join("wiki.toml");
184 if toml_path.exists() {
185 let mut content = std::fs::read_to_string(&toml_path)?;
186 content.push_str(&format!("wiki_root = \"{effective_root}\"\n"));
187 std::fs::write(&toml_path, content)?;
188 } else {
189 std::fs::write(
190 &toml_path,
191 generate_wiki_toml(name, description, &effective_root),
192 )?;
193 }
194 }
195
196 let entry = WikiEntry {
197 name: name.into(),
198 path: path.to_string_lossy().into(),
199 description: description.map(|s| s.into()),
200 remote: None,
201 };
202
203 let global = crate::config::load_global(config_path)?;
204 let already_registered = global.wikis.iter().any(|w| w.name == name);
205 if !already_registered {
206 register(entry, false, config_path)?;
207 }
208
209 Ok(RegisterReport {
210 path: path.to_string_lossy().into(),
211 name: name.into(),
212 registered: !already_registered,
213 created: false,
214 committed: false,
215 })
216}
217
218fn ensure_structure(
219 path: &Path,
220 name: &str,
221 description: Option<&str>,
222 wiki_root: &str,
223) -> Result<()> {
224 for dir in &["inbox", "raw", "schemas"] {
225 let d = path.join(dir);
226 if !d.exists() {
227 std::fs::create_dir_all(&d)?;
228 }
229 let gitkeep = d.join(".gitkeep");
230 if !gitkeep.exists() {
231 std::fs::write(&gitkeep, "")?;
232 }
233 }
234
235 let wiki_dir = path.join(wiki_root);
237 if !wiki_dir.exists() {
238 std::fs::create_dir_all(&wiki_dir)?;
239 }
240 let gitkeep = wiki_dir.join(".gitkeep");
241 if !gitkeep.exists() {
242 std::fs::write(&gitkeep, "")?;
243 }
244
245 let schemas_dir = path.join("schemas");
247 for (filename, content) in default_schemas() {
248 let dest = schemas_dir.join(filename);
249 if !dest.exists() {
250 std::fs::write(&dest, content)?;
251 }
252 }
253
254 for (filename, content) in crate::default_schemas::default_templates() {
256 let dest = schemas_dir.join(filename);
257 if !dest.exists() {
258 std::fs::write(&dest, content)?;
259 }
260 }
261
262 let readme = path.join("README.md");
263 if !readme.exists() {
264 let desc_line = description.map(|d| format!("\n{d}\n")).unwrap_or_default();
265 let content = format!(
266 "# {name}\n{desc_line}\nManaged by [llm-wiki](https://github.com/geronimo-iia/llm-wiki). Run `llm-wiki serve` to start the MCP server.\n"
267 );
268 std::fs::write(&readme, content)?;
269 }
270
271 let wiki_toml = path.join("wiki.toml");
272 if !wiki_toml.exists() {
273 std::fs::write(&wiki_toml, generate_wiki_toml(name, description, wiki_root))?;
274 }
275
276 Ok(())
277}
278
279fn generate_wiki_toml(name: &str, description: Option<&str>, wiki_root: &str) -> String {
280 let mut s = format!("name = \"{name}\"\n");
281 if let Some(desc) = description {
282 s.push_str(&format!("description = \"{desc}\"\n"));
283 }
284 if wiki_root != "wiki" {
285 s.push_str(&format!("wiki_root = \"{wiki_root}\"\n"));
286 }
287 s
288}
289
290pub fn validate_wiki_root(repo_path: &Path, wiki_root: &str) -> Result<()> {
295 if wiki_root.is_empty() || wiki_root == "." {
296 bail!("wiki_root must not be empty or \".\"");
297 }
298 if std::path::Path::new(wiki_root).is_absolute() {
299 bail!("wiki_root must be a relative path (no leading \"/\")");
300 }
301 use std::path::Component;
302 for component in std::path::Path::new(wiki_root).components() {
303 if matches!(component, Component::ParentDir) {
304 bail!("wiki_root must not contain \"..\" components");
305 }
306 }
307 let top = std::path::Path::new(wiki_root)
308 .components()
309 .next()
310 .and_then(|c| {
311 if let Component::Normal(s) = c {
312 Some(s.to_string_lossy().into_owned())
313 } else {
314 None
315 }
316 })
317 .unwrap_or_default();
318 for reserved in &["inbox", "raw", "schemas"] {
319 if top == *reserved {
320 bail!("wiki_root \"{wiki_root}\" uses reserved directory \"{reserved}\"");
321 }
322 }
323 let candidate = repo_path.join(wiki_root);
324 if !candidate.exists() {
325 bail!(
326 "wiki_root directory \"{}\" does not exist",
327 candidate.display()
328 );
329 }
330 let repo_abs = std::fs::canonicalize(repo_path)
331 .with_context(|| format!("cannot canonicalize repo path {}", repo_path.display()))?;
332 let root_abs = std::fs::canonicalize(&candidate)
333 .with_context(|| format!("cannot canonicalize wiki_root {}", candidate.display()))?;
334 if !root_abs.starts_with(&repo_abs) {
335 bail!(
336 "wiki_root must be inside the repository (resolved to {}, repo is {})",
337 root_abs.display(),
338 repo_abs.display()
339 );
340 }
341 Ok(())
342}
343
344pub fn resolve_name(name: &str, global: &GlobalConfig) -> Result<WikiEntry> {
348 global
349 .wikis
350 .iter()
351 .find(|w| w.name == name)
352 .cloned()
353 .ok_or_else(|| anyhow::anyhow!("wiki \"{name}\" is not registered"))
354}
355
356pub fn register(entry: WikiEntry, force: bool, config_path: &Path) -> Result<()> {
358 let mut config = load_global(config_path)?;
359
360 if let Some(existing) = config.wikis.iter_mut().find(|w| w.name == entry.name) {
361 if !force {
362 bail!(
363 "wiki already registered as \"{}\". Use --force to update.",
364 entry.name
365 );
366 }
367 *existing = entry;
368 } else {
369 config.wikis.push(entry);
370 }
371
372 save_global(&config, config_path)
373}
374
375pub fn remove(name: &str, delete: bool, config_path: &Path) -> Result<()> {
377 let mut config = load_global(config_path)?;
378
379 if config.global.default_wiki == name {
380 bail!("\"{name}\" is the default wiki \u{2014} set a new default first");
381 }
382
383 let idx = config
384 .wikis
385 .iter()
386 .position(|w| w.name == name)
387 .ok_or_else(|| anyhow::anyhow!("wiki \"{name}\" is not registered"))?;
388
389 let entry = config.wikis.remove(idx);
390
391 if delete {
392 let path = Path::new(&entry.path);
393 if path.exists() {
394 std::fs::remove_dir_all(path)?;
395 }
396 }
397
398 save_global(&config, config_path)
399}
400
401pub fn load_all(global: &GlobalConfig) -> Vec<WikiEntry> {
403 global.wikis.clone()
404}
405
406pub fn set_default_wiki(name: &str, config_path: &Path) -> Result<()> {
408 let mut config = load_global(config_path)?;
409
410 if !config.wikis.iter().any(|w| w.name == name) {
411 bail!("wiki \"{name}\" is not registered");
412 }
413
414 config.global.default_wiki = name.to_string();
415 save_global(&config, config_path)
416}