use std::path::Path;
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use toml;
use crate::config::{GlobalConfig, WikiEntry, load_global, save_global};
use crate::default_schemas::default_schemas;
use crate::git;
#[derive(Debug, Serialize, Deserialize)]
pub struct CreateReport {
pub path: String,
pub name: String,
pub created: bool,
pub registered: bool,
pub committed: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RegisterReport {
pub path: String,
pub name: String,
pub registered: bool,
pub created: bool,
pub committed: bool,
}
pub fn create(
path: &Path,
name: &str,
description: Option<&str>,
force: bool,
set_default: bool,
config_path: &Path,
wiki_root: Option<&str>,
) -> Result<CreateReport> {
let mut created = false;
if !path.exists() {
std::fs::create_dir_all(path)?;
created = true;
}
let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let wiki_root = wiki_root.unwrap_or("wiki");
let mut committed = false;
let global = load_global(config_path)?;
if let Some(existing) = global
.wikis
.iter()
.find(|w| w.path == path.to_string_lossy())
{
if existing.name == name {
ensure_structure(&path, name, description, wiki_root)?;
return Ok(CreateReport {
path: path.to_string_lossy().into(),
name: name.into(),
created: false,
registered: false,
committed: false,
});
} else if !force {
bail!(
"wiki already registered as \"{}\". Use --force to rename.",
existing.name
);
}
}
ensure_structure(&path, name, description, wiki_root)?;
if !path.join(".git").exists() {
git::init_repo(&path)?;
}
let commit_result = git::commit(&path, &format!("create: {name}"));
if let Ok(ref hash) = commit_result
&& !hash.is_empty()
{
committed = true;
}
let entry = WikiEntry {
name: name.into(),
path: path.to_string_lossy().into(),
description: description.map(|s| s.into()),
remote: None,
};
register(entry, force, config_path)?;
if let Some(wiki_dir) = config_path.parent() {
let logs_dir = wiki_dir.join("logs");
if !logs_dir.exists() {
std::fs::create_dir_all(&logs_dir)?;
}
}
if set_default {
set_default_wiki(name, config_path)?;
}
Ok(CreateReport {
path: path.to_string_lossy().into(),
name: name.into(),
created,
registered: true,
committed,
})
}
pub fn register_existing(
path: &Path,
name: &str,
description: Option<&str>,
wiki_root_override: Option<&str>,
config_path: &Path,
) -> Result<RegisterReport> {
if !path.exists() {
bail!("path \"{}\" does not exist", path.display());
}
let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let existing_toml_root: Option<String> = {
let toml_path = path.join("wiki.toml");
if toml_path.exists() {
let raw = std::fs::read_to_string(&toml_path)?;
if raw.contains("wiki_root") {
let cfg: crate::config::WikiConfig = toml::from_str(&raw).unwrap_or_default();
Some(cfg.wiki_root)
} else {
None
}
} else {
None
}
};
let effective_root: String = match (&wiki_root_override, &existing_toml_root) {
(Some(flag), Some(toml)) if *flag != toml => {
bail!(
"wiki.toml already declares wiki_root = \"{toml}\". \
Remove it manually before registering with a different value."
);
}
(Some(flag), _) => flag.to_string(),
(None, Some(toml)) => toml.clone(),
(None, None) => "wiki".to_string(),
};
validate_wiki_root(&path, &effective_root)?;
if wiki_root_override.is_some() && existing_toml_root.is_none() {
let toml_path = path.join("wiki.toml");
if toml_path.exists() {
let mut content = std::fs::read_to_string(&toml_path)?;
content.push_str(&format!("wiki_root = \"{effective_root}\"\n"));
std::fs::write(&toml_path, content)?;
} else {
std::fs::write(
&toml_path,
generate_wiki_toml(name, description, &effective_root),
)?;
}
}
let entry = WikiEntry {
name: name.into(),
path: path.to_string_lossy().into(),
description: description.map(|s| s.into()),
remote: None,
};
let global = crate::config::load_global(config_path)?;
let already_registered = global.wikis.iter().any(|w| w.name == name);
if !already_registered {
register(entry, false, config_path)?;
}
Ok(RegisterReport {
path: path.to_string_lossy().into(),
name: name.into(),
registered: !already_registered,
created: false,
committed: false,
})
}
fn ensure_structure(
path: &Path,
name: &str,
description: Option<&str>,
wiki_root: &str,
) -> Result<()> {
for dir in &["inbox", "raw", "schemas"] {
let d = path.join(dir);
if !d.exists() {
std::fs::create_dir_all(&d)?;
}
let gitkeep = d.join(".gitkeep");
if !gitkeep.exists() {
std::fs::write(&gitkeep, "")?;
}
}
let wiki_dir = path.join(wiki_root);
if !wiki_dir.exists() {
std::fs::create_dir_all(&wiki_dir)?;
}
let gitkeep = wiki_dir.join(".gitkeep");
if !gitkeep.exists() {
std::fs::write(&gitkeep, "")?;
}
let schemas_dir = path.join("schemas");
for (filename, content) in default_schemas() {
let dest = schemas_dir.join(filename);
if !dest.exists() {
std::fs::write(&dest, content)?;
}
}
for (filename, content) in crate::default_schemas::default_templates() {
let dest = schemas_dir.join(filename);
if !dest.exists() {
std::fs::write(&dest, content)?;
}
}
let readme = path.join("README.md");
if !readme.exists() {
let desc_line = description.map(|d| format!("\n{d}\n")).unwrap_or_default();
let content = format!(
"# {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"
);
std::fs::write(&readme, content)?;
}
let wiki_toml = path.join("wiki.toml");
if !wiki_toml.exists() {
std::fs::write(&wiki_toml, generate_wiki_toml(name, description, wiki_root))?;
}
Ok(())
}
fn generate_wiki_toml(name: &str, description: Option<&str>, wiki_root: &str) -> String {
let mut s = format!("name = \"{name}\"\n");
if let Some(desc) = description {
s.push_str(&format!("description = \"{desc}\"\n"));
}
if wiki_root != "wiki" {
s.push_str(&format!("wiki_root = \"{wiki_root}\"\n"));
}
s
}
pub fn validate_wiki_root(repo_path: &Path, wiki_root: &str) -> Result<()> {
if wiki_root.is_empty() || wiki_root == "." {
bail!("wiki_root must not be empty or \".\"");
}
if std::path::Path::new(wiki_root).is_absolute() {
bail!("wiki_root must be a relative path (no leading \"/\")");
}
use std::path::Component;
for component in std::path::Path::new(wiki_root).components() {
if matches!(component, Component::ParentDir) {
bail!("wiki_root must not contain \"..\" components");
}
}
let top = std::path::Path::new(wiki_root)
.components()
.next()
.and_then(|c| {
if let Component::Normal(s) = c {
Some(s.to_string_lossy().into_owned())
} else {
None
}
})
.unwrap_or_default();
for reserved in &["inbox", "raw", "schemas"] {
if top == *reserved {
bail!("wiki_root \"{wiki_root}\" uses reserved directory \"{reserved}\"");
}
}
let candidate = repo_path.join(wiki_root);
if !candidate.exists() {
bail!(
"wiki_root directory \"{}\" does not exist",
candidate.display()
);
}
let repo_abs = std::fs::canonicalize(repo_path)
.with_context(|| format!("cannot canonicalize repo path {}", repo_path.display()))?;
let root_abs = std::fs::canonicalize(&candidate)
.with_context(|| format!("cannot canonicalize wiki_root {}", candidate.display()))?;
if !root_abs.starts_with(&repo_abs) {
bail!(
"wiki_root must be inside the repository (resolved to {}, repo is {})",
root_abs.display(),
repo_abs.display()
);
}
Ok(())
}
pub fn resolve_name(name: &str, global: &GlobalConfig) -> Result<WikiEntry> {
global
.wikis
.iter()
.find(|w| w.name == name)
.cloned()
.ok_or_else(|| anyhow::anyhow!("wiki \"{name}\" is not registered"))
}
pub fn register(entry: WikiEntry, force: bool, config_path: &Path) -> Result<()> {
let mut config = load_global(config_path)?;
if let Some(existing) = config.wikis.iter_mut().find(|w| w.name == entry.name) {
if !force {
bail!(
"wiki already registered as \"{}\". Use --force to update.",
entry.name
);
}
*existing = entry;
} else {
config.wikis.push(entry);
}
save_global(&config, config_path)
}
pub fn remove(name: &str, delete: bool, config_path: &Path) -> Result<()> {
let mut config = load_global(config_path)?;
if config.global.default_wiki == name {
bail!("\"{name}\" is the default wiki \u{2014} set a new default first");
}
let idx = config
.wikis
.iter()
.position(|w| w.name == name)
.ok_or_else(|| anyhow::anyhow!("wiki \"{name}\" is not registered"))?;
let entry = config.wikis.remove(idx);
if delete {
let path = Path::new(&entry.path);
if path.exists() {
std::fs::remove_dir_all(path)?;
}
}
save_global(&config, config_path)
}
pub fn load_all(global: &GlobalConfig) -> Vec<WikiEntry> {
global.wikis.clone()
}
pub fn set_default_wiki(name: &str, config_path: &Path) -> Result<()> {
let mut config = load_global(config_path)?;
if !config.wikis.iter().any(|w| w.name == name) {
bail!("wiki \"{name}\" is not registered");
}
config.global.default_wiki = name.to_string();
save_global(&config, config_path)
}