use std::fs;
use std::path::PathBuf;
use crate::session::layout;
const SLUG_MAX_LEN: usize = 64;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WikiError {
SlugInvalid(String),
AlreadyExists(String),
NotFound(String),
Io(String),
}
impl std::fmt::Display for WikiError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
WikiError::SlugInvalid(s) => write!(f, "wiki_slug_invalid: {s}"),
WikiError::AlreadyExists(s) => write!(f, "wiki_page_exists: {s}"),
WikiError::NotFound(s) => write!(f, "wiki_page_not_found: {s}"),
WikiError::Io(s) => write!(f, "wiki_io: {s}"),
}
}
}
pub fn validate_slug(slug: &str) -> Result<(), WikiError> {
if slug.is_empty() {
return Err(WikiError::SlugInvalid("empty".into()));
}
if slug.len() > SLUG_MAX_LEN {
return Err(WikiError::SlugInvalid(format!(
"length {} > {SLUG_MAX_LEN}",
slug.len()
)));
}
for (i, c) in slug.chars().enumerate() {
let ok = c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_';
if !ok {
return Err(WikiError::SlugInvalid(format!(
"char {c:?} at pos {i} — allowed: [a-z0-9_-]"
)));
}
}
Ok(())
}
pub fn create_page(session_slug: &str, page_slug: &str, body: &str) -> Result<PathBuf, WikiError> {
create_page_in(&layout::session_wiki_dir(session_slug), page_slug, body)
}
pub fn replace_page(session_slug: &str, page_slug: &str, body: &str) -> Result<PathBuf, WikiError> {
replace_page_in(&layout::session_wiki_dir(session_slug), page_slug, body)
}
pub fn append_page(
session_slug: &str,
page_slug: &str,
body: &str,
stamp: &str,
) -> Result<PathBuf, WikiError> {
append_page_in(
&layout::session_wiki_dir(session_slug),
page_slug,
body,
stamp,
)
}
pub fn read_page(session_slug: &str, page_slug: &str) -> Result<String, WikiError> {
read_page_in(&layout::session_wiki_dir(session_slug), page_slug)
}
pub fn remove_page(session_slug: &str, page_slug: &str) -> Result<PathBuf, WikiError> {
remove_page_in(&layout::session_wiki_dir(session_slug), page_slug)
}
pub fn list_pages(session_slug: &str) -> Vec<String> {
list_pages_in(&layout::session_wiki_dir(session_slug))
}
pub fn create_page_in(
wiki_dir: &std::path::Path,
page_slug: &str,
body: &str,
) -> Result<PathBuf, WikiError> {
validate_slug(page_slug)?;
let path = wiki_dir.join(format!("{page_slug}.md"));
if path.exists() {
return Err(WikiError::AlreadyExists(page_slug.to_string()));
}
fs::create_dir_all(wiki_dir).map_err(|e| WikiError::Io(format!("mkdir wiki/: {e}")))?;
fs::write(&path, body).map_err(|e| WikiError::Io(format!("write {page_slug}: {e}")))?;
Ok(path)
}
pub fn replace_page_in(
wiki_dir: &std::path::Path,
page_slug: &str,
body: &str,
) -> Result<PathBuf, WikiError> {
validate_slug(page_slug)?;
fs::create_dir_all(wiki_dir).map_err(|e| WikiError::Io(format!("mkdir wiki/: {e}")))?;
let path = wiki_dir.join(format!("{page_slug}.md"));
fs::write(&path, body).map_err(|e| WikiError::Io(format!("write {page_slug}: {e}")))?;
Ok(path)
}
pub fn append_page_in(
wiki_dir: &std::path::Path,
page_slug: &str,
body: &str,
stamp: &str,
) -> Result<PathBuf, WikiError> {
validate_slug(page_slug)?;
fs::create_dir_all(wiki_dir).map_err(|e| WikiError::Io(format!("mkdir wiki/: {e}")))?;
let path = wiki_dir.join(format!("{page_slug}.md"));
let prior = fs::read_to_string(&path).unwrap_or_default();
let sep = if prior.trim().is_empty() { "" } else { "\n\n" };
let block = format!("{prior}{sep}<!-- appended {stamp} -->\n{body}");
fs::write(&path, block.trim_start())
.map_err(|e| WikiError::Io(format!("append {page_slug}: {e}")))?;
Ok(path)
}
pub fn read_page_in(wiki_dir: &std::path::Path, page_slug: &str) -> Result<String, WikiError> {
validate_slug(page_slug)?;
let path = wiki_dir.join(format!("{page_slug}.md"));
fs::read_to_string(&path).map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => WikiError::NotFound(page_slug.to_string()),
_ => WikiError::Io(e.to_string()),
})
}
pub fn remove_page_in(wiki_dir: &std::path::Path, page_slug: &str) -> Result<PathBuf, WikiError> {
validate_slug(page_slug)?;
let path = wiki_dir.join(format!("{page_slug}.md"));
if !path.exists() {
return Err(WikiError::NotFound(page_slug.to_string()));
}
fs::remove_file(&path).map_err(|e| WikiError::Io(e.to_string()))?;
Ok(path)
}
pub fn list_pages_in(wiki_dir: &std::path::Path) -> Vec<String> {
let Ok(entries) = fs::read_dir(wiki_dir) else {
return Vec::new();
};
let mut out: Vec<String> = entries
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some("md"))
.filter_map(|e| {
e.path()
.file_stem()
.and_then(|s| s.to_str())
.map(str::to_string)
})
.collect();
out.sort();
out
}
pub fn seed_manual_page_in(
wiki_dir: &std::path::Path,
page_slug: &str,
frontmatter_lines: &[(&str, String)],
body: &str,
reseed: bool,
) -> Result<PathBuf, WikiError> {
validate_slug(page_slug)?;
let path = wiki_dir.join(format!("{page_slug}.md"));
if path.exists() && !reseed {
return Err(WikiError::AlreadyExists(page_slug.to_string()));
}
fs::create_dir_all(wiki_dir).map_err(|e| WikiError::Io(format!("mkdir wiki/: {e}")))?;
let mut content = String::with_capacity(256 + body.len());
content.push_str("---\n");
for (k, v) in frontmatter_lines {
content.push_str(k);
content.push_str(": ");
if v.starts_with(' ') || v.contains('"') || v.contains('\n') {
content.push('"');
content.push_str(&v.replace('"', "\\\""));
content.push('"');
} else {
content.push_str(v);
}
content.push('\n');
}
content.push_str("---\n");
content.push_str(body);
fs::write(&path, &content).map_err(|e| WikiError::Io(format!("write {page_slug}: {e}")))?;
Ok(path)
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Frontmatter {
pub kind: Option<String>,
pub sources: Vec<String>,
pub related: Vec<String>,
pub updated: Option<String>,
pub parts: Vec<String>,
pub extra: Vec<(String, String)>,
}
pub fn split_frontmatter(body: &str) -> (Frontmatter, &str) {
let trimmed = body.trim_start_matches('\u{feff}'); if !trimmed.starts_with("---\n") && !trimmed.starts_with("---\r\n") {
return (Frontmatter::default(), body);
}
let after_open = &trimmed[4..];
let Some(close_rel) = after_open
.find("\n---\n")
.or_else(|| after_open.find("\n---\r\n"))
else {
return (Frontmatter::default(), body);
};
let yaml = &after_open[..close_rel];
let rest_start = 4 + close_rel + "\n---\n".len();
let rest = trimmed.get(rest_start..).unwrap_or("");
let fm = parse_simple_yaml(yaml);
(fm, rest)
}
pub fn render_frontmatter_body(fm: &Frontmatter) -> String {
let mut out = String::new();
if let Some(kind) = &fm.kind {
out.push_str(&format!("kind: {kind}\n"));
}
if !fm.sources.is_empty() {
out.push_str(&format!("sources: {}\n", render_yaml_list(&fm.sources)));
}
if !fm.parts.is_empty() {
out.push_str(&format!("parts: {}\n", render_yaml_list(&fm.parts)));
}
if !fm.related.is_empty() {
out.push_str(&format!("related: {}\n", render_yaml_list(&fm.related)));
}
if let Some(updated) = &fm.updated {
out.push_str(&format!("updated: {updated}\n"));
}
for (k, v) in &fm.extra {
out.push_str(&format!("{k}: {v}\n"));
}
out
}
fn render_yaml_list(items: &[String]) -> String {
let inner: Vec<String> = items
.iter()
.map(|s| {
if s.contains(',') || s.contains('[') || s.contains(']') || s.contains('"') {
format!("\"{}\"", s.replace('"', "\\\""))
} else {
s.clone()
}
})
.collect();
format!("[{}]", inner.join(", "))
}
fn parse_simple_yaml(yaml: &str) -> Frontmatter {
let mut fm = Frontmatter::default();
for line in yaml.lines() {
let line = line.trim_end();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((k, v)) = line.split_once(':') else {
continue;
};
let key = k.trim();
let val = v.trim();
match key {
"kind" => fm.kind = Some(strip_quotes(val).to_string()),
"updated" => fm.updated = Some(strip_quotes(val).to_string()),
"sources" => fm.sources = parse_yaml_list(val),
"related" => fm.related = parse_yaml_list(val),
"parts" => fm.parts = parse_yaml_list(val),
_ => fm.extra.push((key.to_string(), val.to_string())),
}
}
fm
}
fn strip_quotes(s: &str) -> &str {
let t = s.trim();
if ((t.starts_with('"') && t.ends_with('"')) || (t.starts_with('\'') && t.ends_with('\'')))
&& t.len() >= 2
{
return &t[1..t.len() - 1];
}
t
}
fn parse_yaml_list(val: &str) -> Vec<String> {
let t = val.trim();
if !t.starts_with('[') || !t.ends_with(']') {
if t.is_empty() {
return Vec::new();
}
return vec![strip_quotes(t).to_string()];
}
let inner = &t[1..t.len() - 1];
let mut out: Vec<String> = Vec::new();
let mut buf = String::new();
let mut quote: Option<char> = None;
for c in inner.chars() {
match (c, quote) {
('"', None) => quote = Some('"'),
('\'', None) => quote = Some('\''),
(c, Some(q)) if c == q => quote = None,
(',', None) => {
let s = strip_quotes(buf.trim()).to_string();
if !s.is_empty() {
out.push(s);
}
buf.clear();
}
_ => buf.push(c),
}
}
let tail = strip_quotes(buf.trim()).to_string();
if !tail.is_empty() {
out.push(tail);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp_wiki_dir() -> tempfile::TempDir {
tempfile::tempdir().unwrap()
}
#[test]
fn slug_accepts_lowercase_digits_hyphen_underscore() {
for s in ["a", "a-b", "a_b", "abc123", "x-1_2", &"a".repeat(64)] {
assert!(validate_slug(s).is_ok(), "{s} should be valid");
}
}
#[test]
fn slug_rejects_empty_too_long_and_bad_chars() {
assert!(matches!(validate_slug(""), Err(WikiError::SlugInvalid(_))));
assert!(matches!(
validate_slug(&"a".repeat(65)),
Err(WikiError::SlugInvalid(_))
));
for s in [
"Scheduler",
"with.dot",
"with/slash",
"spaces here",
"bang!",
] {
assert!(
matches!(validate_slug(s), Err(WikiError::SlugInvalid(_))),
"{s}"
);
}
}
#[test]
fn create_then_read() {
let tmp = tmp_wiki_dir();
let p = create_page_in(tmp.path(), "scheduler", "# Scheduler\nBody.").unwrap();
assert!(p.exists());
let got = read_page_in(tmp.path(), "scheduler").unwrap();
assert!(got.contains("# Scheduler"));
}
#[test]
fn create_twice_fails() {
let tmp = tmp_wiki_dir();
create_page_in(tmp.path(), "x", "a").unwrap();
match create_page_in(tmp.path(), "x", "b") {
Err(WikiError::AlreadyExists(_)) => {}
other => panic!("expected AlreadyExists, got {other:?}"),
}
}
#[test]
fn replace_overwrites() {
let tmp = tmp_wiki_dir();
replace_page_in(tmp.path(), "x", "first").unwrap();
replace_page_in(tmp.path(), "x", "second").unwrap();
assert_eq!(read_page_in(tmp.path(), "x").unwrap(), "second");
}
#[test]
fn append_preserves_prior_content() {
let tmp = tmp_wiki_dir();
create_page_in(tmp.path(), "x", "original body").unwrap();
append_page_in(tmp.path(), "x", "new paragraph", "2026-04-21").unwrap();
let got = read_page_in(tmp.path(), "x").unwrap();
assert!(got.contains("original body"));
assert!(got.contains("appended 2026-04-21"));
assert!(got.contains("new paragraph"));
}
#[test]
fn append_creates_when_missing() {
let tmp = tmp_wiki_dir();
append_page_in(tmp.path(), "brand-new", "first line", "2026-04-21").unwrap();
let got = read_page_in(tmp.path(), "brand-new").unwrap();
assert!(got.contains("appended 2026-04-21"));
assert!(got.contains("first line"));
}
#[test]
fn remove_works_and_reports_missing() {
let tmp = tmp_wiki_dir();
create_page_in(tmp.path(), "x", "body").unwrap();
remove_page_in(tmp.path(), "x").unwrap();
assert!(matches!(
read_page_in(tmp.path(), "x"),
Err(WikiError::NotFound(_))
));
assert!(matches!(
remove_page_in(tmp.path(), "x"),
Err(WikiError::NotFound(_))
));
}
#[test]
fn list_returns_sorted_slugs() {
let tmp = tmp_wiki_dir();
for name in ["beta", "alpha", "gamma"] {
create_page_in(tmp.path(), name, "x").unwrap();
}
assert_eq!(list_pages_in(tmp.path()), vec!["alpha", "beta", "gamma"]);
}
#[test]
fn list_on_missing_dir_returns_empty() {
let missing = std::path::Path::new("/tmp/never-existed-wiki-xyz-abc");
assert!(list_pages_in(missing).is_empty());
}
#[test]
fn split_frontmatter_absent() {
let (fm, rest) = split_frontmatter("just body\n");
assert_eq!(fm, Frontmatter::default());
assert_eq!(rest, "just body\n");
}
#[test]
fn split_frontmatter_parses_all_known_fields() {
let body = "---\nkind: concept\nsources: [https://a.test, https://b.test]\nrelated: [foo, bar]\nupdated: 2026-04-21\ncustom: xyz\n---\n# Page\nBody.";
let (fm, rest) = split_frontmatter(body);
assert_eq!(fm.kind.as_deref(), Some("concept"));
assert_eq!(fm.sources, vec!["https://a.test", "https://b.test"]);
assert_eq!(fm.related, vec!["foo", "bar"]);
assert_eq!(fm.updated.as_deref(), Some("2026-04-21"));
assert_eq!(fm.extra, vec![("custom".into(), "xyz".into())]);
assert_eq!(rest, "# Page\nBody.");
}
#[test]
fn split_frontmatter_handles_quoted_values() {
let body = "---\nkind: \"source-summary\"\nupdated: '2026-04-21'\n---\ndone";
let (fm, _) = split_frontmatter(body);
assert_eq!(fm.kind.as_deref(), Some("source-summary"));
assert_eq!(fm.updated.as_deref(), Some("2026-04-21"));
}
#[test]
fn split_frontmatter_tolerates_unclosed_block() {
let body = "---\nkind: bad\n(no close)";
let (fm, rest) = split_frontmatter(body);
assert_eq!(fm, Frontmatter::default());
assert_eq!(rest, body);
}
}