Skip to main content

llm_wiki/
spaces.rs

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// ── CreateReport ──────────────────────────────────────────────────────────────
12
13/// Outcome of a wiki space creation.
14#[derive(Debug, Serialize, Deserialize)]
15pub struct CreateReport {
16    /// Absolute path of the wiki directory.
17    pub path: String,
18    /// Registered name of the wiki.
19    pub name: String,
20    /// True if the directory was newly created.
21    pub created: bool,
22    /// True if the space was added to the global config.
23    pub registered: bool,
24    /// True if an initial git commit was made.
25    pub committed: bool,
26}
27
28/// Outcome of registering an existing wiki space.
29#[derive(Debug, Serialize, Deserialize)]
30pub struct RegisterReport {
31    /// Absolute path of the wiki directory.
32    pub path: String,
33    /// Registered name of the wiki.
34    pub name: String,
35    /// True if the space was added to the global config (false if already registered).
36    pub registered: bool,
37    /// Always false — register does not create directories.
38    pub created: bool,
39    /// Always false — register does not create git commits.
40    pub committed: bool,
41}
42
43// ── create ────────────────────────────────────────────────────────────────────
44
45/// Create a new wiki repository at `path`, register it, and optionally commit.
46pub 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    // Check re-run conditions
65    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    // Git init if not already a repo
91    if !path.join(".git").exists() {
92        git::init_repo(&path)?;
93    }
94
95    // Initial commit
96    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    // Register
104    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    // Ensure global engine directories exist
113    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
133/// Register an existing wiki repository without creating files.
134///
135/// Reads `wiki.toml` from `path` (if it exists) to determine `wiki_root`.
136/// If `wiki_root_override` is given and `wiki.toml` already declares a
137/// different `wiki_root`, returns an error.
138pub 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    // Read existing wiki.toml wiki_root if present
151    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    // Conflict check
167    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 set and toml doesn't have it, write it into wiki.toml
182    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    // Create the (possibly custom) wiki content directory
236    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    // Write embedded default schemas
246    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    // Write embedded default body templates
255    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
290/// Validate `wiki_root` before using it at registration time.
291///
292/// Checks: non-empty, relative, no `..`, not a reserved dir, directory exists,
293/// and resolves to a path strictly inside `repo_path`.
294pub 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
344// ── Space management ──────────────────────────────────────────────────────────
345
346/// Look up a registered wiki by name; error if not found.
347pub 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
356/// Add or update a wiki entry in the global config; errors if already registered and `force` is false.
357pub 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
375/// Unregister a wiki from the global config; optionally delete its directory.
376pub 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
401/// Return all registered wiki entries from the global config.
402pub fn load_all(global: &GlobalConfig) -> Vec<WikiEntry> {
403    global.wikis.clone()
404}
405
406/// Set the default wiki in the global config.
407pub 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}