use crate::api::ApiError;
use crate::api::browse::WikiPage;
use crate::api::model::RepoId;
use crate::git::commit;
use crate::git::repo::Repo;
use crate::diff_job::GitContext;
pub(crate) mod form;
const HEAD: &str = "refs/ghtui/wiki";
const MARKUPS: &[&str] = &[
"md",
"markdown",
"mediawiki",
"wiki",
"textile",
"rdoc",
"org",
"creole",
"rst",
"asciidoc",
"adoc",
"pod",
];
fn page_file(path: &str) -> Option<(&str, &str)> {
let (title, ext) = path.rsplit_once('.')?;
MARKUPS
.contains(&ext.to_ascii_lowercase().as_str())
.then_some((title, ext))
}
pub fn slug(title: &str) -> String {
title.replace(' ', "-")
}
fn absent(err: &crate::git::GitError) -> bool {
matches!(err, crate::git::GitError::Failed { stderr, .. }
if stderr.contains("fatal: repository '") && stderr.contains("' not found"))
}
async fn open(git: &GitContext, repo: &RepoId) -> Result<Repo, crate::git::GitError> {
let name = format!("{}.wiki", repo.name);
let url = crate::host::current().git_url(&repo.owner, &name);
Repo::open_cache(
&git.cache_root,
&repo.owner,
&name,
&url,
git.credentials.clone(),
&|_| {},
)
.await
}
pub async fn page(
git: &GitContext,
repo: &RepoId,
page: Option<&str>,
) -> Result<WikiPage, ApiError> {
let unread = |err: &dyn std::fmt::Display| ApiError::Git(format!("{repo}'s wiki: {err}"));
let failed = |err: &crate::git::GitError| {
if absent(err) {
ApiError::NotFound(format!(
"{repo}'s wiki: it's turned off, or has no pages yet (GitHub makes the first on github.com)"
))
} else {
unread(err)
}
};
let wiki = open(git, repo).await.map_err(|e| failed(&e))?;
if let Err(err) = wiki.fetch_head(HEAD, &|_| {}).await
&& !wiki.has(HEAD).await
{
return Err(failed(&err));
}
read(&wiki, page).await.map_err(|e| match e {
Read::Git(err) => unread(&err),
Read::NoPage(wanted) => ApiError::NotFound(format!("{repo}'s wiki has no page {wanted}")),
})
}
enum Read {
Git(crate::git::GitError),
NoPage(String),
}
impl From<crate::git::GitError> for Read {
fn from(err: crate::git::GitError) -> Self {
Read::Git(err)
}
}
async fn read(wiki: &Repo, page: Option<&str>) -> Result<WikiPage, Read> {
let commit = wiki.rev_parse(HEAD).await?.to_string();
let files = wiki.file_names(&commit).await?;
let pages: Vec<(&str, &str, &String)> = files
.iter()
.filter_map(|f| page_file(f).map(|(title, ext)| (title, ext, f)))
.collect();
let mut titles: Vec<String> = pages
.iter()
.map(|(t, _, _)| title_of(t))
.filter(|t| !t.starts_with('_'))
.collect();
titles.sort_by_key(|t| t.to_lowercase());
let find = |wanted: &str| {
pages
.iter()
.find(|(t, _, _)| slug(&title_of(t)).eq_ignore_ascii_case(&slug(wanted)))
};
let sidebar = match find("_Sidebar") {
Some((_, _, path)) => Some(wiki.file_text(&commit, path).await?),
None => None,
};
let wanted = page.unwrap_or("Home");
let found = if wanted == "_pages" {
None
} else {
find(wanted)
};
let (title, text, markdown, file) = match found {
Some((t, ext, path)) => {
let text = wiki.file_text(&commit, path).await?;
let markdown = matches!(ext.to_ascii_lowercase().as_str(), "md" | "markdown");
(
Some(title_of(t)),
Some(text),
markdown,
Some((*path).clone()),
)
}
None if page.is_none() || wanted == "_pages" => (None, None, false, None),
None => return Err(Read::NoPage(wanted.to_owned())),
};
Ok(WikiPage {
title,
text,
markdown,
pages: titles,
sidebar,
commit: Some(commit),
file,
})
}
fn title_of(path: &str) -> String {
path.rsplit('/').next().unwrap_or(path).to_owned()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Save {
pub repo: RepoId,
pub title: String,
pub file: Option<String>,
pub base: Option<String>,
pub text: String,
pub login: Option<String>,
}
pub async fn save(git: &GitContext, save: &Save) -> Result<String, String> {
let repo = &save.repo;
let wiki = open(git, repo).await.map_err(|err| {
if absent(&err) {
no_wiki(repo)
} else {
format!("Couldn't read {repo}'s wiki: {err}")
}
})?;
save_in(&wiki, save).await
}
fn no_wiki(repo: &RepoId) -> String {
format!(
"{repo}'s wiki has no pages yet, and GitHub makes its git repository only with the first page: create that on github.com"
)
}
async fn save_in(wiki: &Repo, save: &Save) -> Result<String, String> {
let repo = &save.repo;
let failed = |err: crate::git::GitError| format!("Couldn't save the page: {err}");
for attempt in 0..2 {
wiki.fetch_head(HEAD, &|_| {}).await.map_err(|err| {
if absent(&err) {
no_wiki(repo)
} else {
format!("Couldn't fetch {repo}'s wiki: {err}")
}
})?;
let head = wiki.rev_parse(HEAD).await.map_err(failed)?.to_string();
let file = match &save.file {
Some(file) => {
let changed = match &save.base {
Some(base) if *base != head => {
commit::blob(wiki, base, file).await
!= commit::blob(wiki, &head, file).await
}
_ => false,
};
if changed {
return Err(format!(
"{} changed on GitHub since you opened it: saving would overwrite that, so it isn't saved. Your text is kept here; open the page again to see GitHub's",
save.title
));
}
file.clone()
}
None => {
let files = wiki.file_names(&head).await.map_err(failed)?;
let taken = (files.iter())
.filter_map(|f| page_file(f))
.any(|(t, _)| slug(&title_of(t)).eq_ignore_ascii_case(&slug(&save.title)));
if taken {
return Err(format!(
"The wiki has a page named {} already: choose another title",
save.title
));
}
format!("{}.md", slug(&save.title))
}
};
let made = make(wiki, &head, &file, save).await.map_err(failed)?;
let branch = branch(wiki).await;
let spec = format!("{made}:refs/heads/{branch}");
let push = ["push", "--quiet", wiki.remote.as_str(), spec.as_str()];
match wiki.git(&push, &[], None).await {
Ok(_) => {
wiki.git(&["update-ref", HEAD, &made], &[], None)
.await
.map_err(failed)?;
return Ok(slug(&save.title));
}
Err(err) if attempt == 0 && commit::moved_on(&err) => {}
Err(crate::git::GitError::Failed { stderr, .. }) => {
return Err(format!("GitHub refused the page: {}", stderr.trim()));
}
Err(err) => return Err(failed(err)),
}
}
Err(format!(
"{repo}'s wiki kept changing while saving: save again"
))
}
async fn branch(wiki: &Repo) -> String {
let out = wiki
.git(&["ls-remote", "--symref", &wiki.remote, "HEAD"], &[], None)
.await
.unwrap_or_default();
out.lines()
.find_map(|l| l.strip_prefix("ref: refs/heads/")?.split('\t').next())
.map_or_else(|| "master".to_owned(), str::to_owned)
}
async fn make(
wiki: &Repo,
parent: &str,
file: &str,
save: &Save,
) -> Result<String, crate::git::GitError> {
let mut text = save.text.clone();
if !text.ends_with('\n') {
text.push('\n');
}
let markup = match file.rsplit_once('.') {
Some((_, "md" | "markdown")) | None => "markdown",
Some((_, ext)) => ext,
};
let verb = if save.file.is_some() {
"Updated"
} else {
"Created"
};
let change = commit::Change {
additions: &[(file.to_owned(), text)],
deletions: &[],
message: &format!("{verb} {} ({markup})", save.title),
login: save.login.as_deref(),
};
commit::make(wiki, parent, &change).await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pages_are_files_in_wiki_markups() {
assert_eq!(page_file("Home.md"), Some(("Home", "md")));
assert_eq!(
page_file("Doc FAQ.mediawiki"),
Some(("Doc FAQ", "mediawiki"))
);
assert_eq!(page_file("logo.png"), None);
assert_eq!(page_file(".gitignore"), None);
assert_eq!(
slug("Doc continuous integration"),
"Doc-continuous-integration"
);
}
#[tokio::test]
async fn a_wiki_git_cant_read_isnt_taken_for_none() {
let dir = tempfile::tempdir().unwrap();
let cache_root = dir.path().join("a-file");
std::fs::write(&cache_root, "").unwrap();
let git = GitContext {
cache_root,
credentials: crate::git::credentials::Credentials::Ambient,
cwd: dir.path().to_owned(),
};
let err = page(&git, &RepoId::new("o", "r"), None).await.unwrap_err();
assert!(matches!(err, ApiError::Git(_)), "{err:?}");
}
#[test]
fn only_a_missing_repository_is_no_wiki() {
let failed = |stderr: &str| crate::git::GitError::Failed {
args: "fetch".into(),
stderr: stderr.into(),
};
let missing = "remote: Repository not found.\nfatal: repository 'https://github.com/o/r.wiki.git/' not found\n";
assert!(absent(&failed(missing)));
let missing = "Cloning into bare repository 'x'...; remote: Repository not found.; fatal: repository 'https://github.com/o/r.wiki.git/' not found";
assert!(absent(&failed(missing)));
let auth =
"fatal: could not read Username for 'https://github.com': terminal prompts disabled";
assert!(!absent(&failed(auth)));
assert!(!absent(&failed(
"fatal: unable to access 'https://github.com/o/r.wiki.git/': Could not resolve host: github.com"
)));
assert!(!absent(&crate::git::GitError::Spawn(
std::io::ErrorKind::NotFound.into()
)));
}
#[tokio::test]
#[ignore = "clones a wiki from GitHub"]
async fn real_wiki() {
let cache_root = std::env::var_os("GHTUI_WIKI_CACHE")
.expect("GHTUI_WIKI_CACHE")
.into();
let git = GitContext {
cache_root,
credentials: crate::git::credentials::Credentials::Ambient,
cwd: std::env::current_dir().unwrap(),
};
let repo = RepoId::new("rust-lang", "rust");
let home = page(&git, &repo, None).await.unwrap();
assert_eq!(home.title.as_deref(), Some("Home"));
assert!(home.pages.len() > 50, "{:?}", home.pages);
let faq = page(&git, &repo, Some("Doc-language-faq")).await.unwrap();
assert_eq!(faq.title.as_deref(), Some("Doc-language-faq"));
let spaced = page(&git, &repo, Some("Doc-continuous-integration"))
.await
.unwrap();
assert!(spaced.text.is_some());
let list = page(&git, &repo, Some("_pages")).await.unwrap();
assert!(list.text.is_none() && !list.pages.is_empty());
page(&git, &repo, Some("No-such-page")).await.unwrap_err();
}
async fn run(dir: &std::path::Path, args: &[&str]) -> String {
crate::git::run(Some(dir), args).await.unwrap()
}
async fn push_page(dir: &std::path::Path, file: &str, text: &str) {
std::fs::write(dir.join(file), text).unwrap();
run(dir, &["add", file]).await;
run(dir, &["commit", "-q", "-m", file]).await;
run(dir, &["push", "-q", "origin", "master"]).await;
}
async fn wiki_setup() -> (tempfile::TempDir, Repo, std::path::PathBuf) {
let dir = tempfile::tempdir().unwrap();
let github = dir.path().join("github.wiki.git");
let theirs = dir.path().join("theirs");
std::fs::create_dir_all(&theirs).unwrap();
run(
dir.path(),
&["init", "-q", "--bare", "-b", "master", "github.wiki.git"],
)
.await;
run(&github, &["config", "uploadpack.allowFilter", "true"]).await;
run(&theirs, &["init", "-q", "-b", "master"]).await;
for (key, value) in [("user.name", "Them"), ("user.email", "them@example.com")] {
run(&theirs, &["config", key, value]).await;
}
let url = format!("file://{}", github.display());
run(&theirs, &["remote", "add", "origin", &url]).await;
push_page(&theirs, "Home.md", "Welcome.\n").await;
let quiet = |_: String| {};
let cache = dir.path().join("cache");
let ours = Repo::open_cache(
&cache,
"o",
"r.wiki",
&url,
crate::git::credentials::Credentials::Ambient,
&quiet,
)
.await
.unwrap();
assert!(ours.partial);
ours.fetch_head(HEAD, &quiet).await.unwrap();
(dir, ours, theirs)
}
fn edit(w: &WikiPage, text: &str) -> Save {
Save {
repo: RepoId::new("o", "r"),
title: w.title.clone().unwrap(),
file: w.file.clone(),
base: w.commit.clone(),
text: text.into(),
login: Some("octocat".into()),
}
}
#[tokio::test]
async fn a_page_is_saved_on_top_of_githubs_latest() {
let (_dir, ours, theirs) = wiki_setup().await;
let home = read(&ours, None).await.ok().unwrap();
assert_eq!(home.file.as_deref(), Some("Home.md"));
push_page(&theirs, "FAQ.md", "Questions.\n").await;
let saved = save_in(&ours, &edit(&home, "Welcome, all.")).await;
assert_eq!(saved.as_deref(), Ok("Home"));
let again = read(&ours, None).await.ok().unwrap();
assert_eq!(again.text.as_deref(), Some("Welcome, all.\n"));
assert_eq!(again.pages, ["FAQ", "Home"]);
run(&theirs, &["pull", "-q", "origin", "master"]).await;
let log = run(&theirs, &["log", "-1", "--format=%s|%an"]).await;
assert!(log.starts_with("Updated Home (markdown)|"), "{log}");
assert_eq!(
std::fs::read_to_string(theirs.join("Home.md")).unwrap(),
"Welcome, all.\n"
);
}
#[tokio::test]
async fn a_page_changed_meanwhile_is_never_overwritten() {
let (_dir, ours, theirs) = wiki_setup().await;
let home = read(&ours, None).await.ok().unwrap();
push_page(&theirs, "Home.md", "Theirs.\n").await;
let refused = save_in(&ours, &edit(&home, "Mine.")).await.unwrap_err();
assert!(refused.contains("Home changed on GitHub"), "{refused}");
let now = read(&ours, None).await.ok().unwrap();
assert_eq!(now.text.as_deref(), Some("Theirs.\n"));
}
#[tokio::test]
async fn a_new_page_is_made_once() {
let (_dir, ours, _theirs) = wiki_setup().await;
let new = Save {
repo: RepoId::new("o", "r"),
title: "Getting started".into(),
file: None,
base: None,
text: "Steps.".into(),
login: None,
};
assert_eq!(save_in(&ours, &new).await.as_deref(), Ok("Getting-started"));
let page = read(&ours, Some("Getting-started")).await.ok().unwrap();
assert_eq!(page.file.as_deref(), Some("Getting-started.md"));
assert_eq!(page.text.as_deref(), Some("Steps.\n"));
let again = save_in(&ours, &new).await.unwrap_err();
assert!(again.contains("has a page named"), "{again}");
}
}