mod common;
use std::collections::BTreeMap;
use std::path::PathBuf;
use common::*;
use serde_json::Value;
async fn run_in(cli: &Cli, dir: PathBuf, args: &[&str]) -> Out {
let mut cmd = cli.command(args);
cmd.current_dir(dir);
tokio::task::spawn_blocking(move || {
let o = cmd.output().expect("run semora");
Out {
code: o.status.code().unwrap_or(-1),
stdout: String::from_utf8_lossy(&o.stdout).into_owned(),
stderr: String::from_utf8_lossy(&o.stderr).into_owned(),
}
})
.await
.unwrap()
}
fn read(cli: &Cli, rel: &str) -> String {
std::fs::read_to_string(cli.path(rel)).unwrap_or_else(|e| panic!("{rel}: {e}"))
}
async fn ok(cli: &Cli, args: &[&str]) -> Out {
let out = cli.run(args).await;
assert_eq!(
out.code, 0,
"{args:?}\nstdout:\n{}\nstderr:\n{}",
out.stdout, out.stderr
);
out
}
async fn two_checkouts(server: &Server) -> (Cli, Cli) {
let a = Cli::new(server);
a.write("docs/plan.md", "# Plan\none\n");
a.write("docs/site/index.md", "# Site\n");
a.write("docs/site/style.css", "body { color: red }\n");
a.write("notes.md", "# Notes\n");
ok(&a, &["project", "init", ".", "--name", "proj"]).await;
for p in ["docs/plan.md", "docs/site", "notes.md"] {
ok(&a, &["push", p]).await;
}
let b = Cli::new(server);
b.write(
".semora",
format!("server = \"{}\"\nproject = \"proj\"\n", server.url),
);
(a, b)
}
fn paths(v: &Value, list: &str) -> Vec<String> {
let mut out: Vec<String> = v[list]
.as_array()
.unwrap_or_else(|| panic!("no {list} in {v}"))
.iter()
.map(|d| d["path"].as_str().unwrap().to_string())
.collect();
out.sort();
out
}
fn note_of(v: &Value, list: &str, path: &str) -> String {
v[list]
.as_array()
.unwrap()
.iter()
.find(|d| d["path"] == path)
.and_then(|d| d["note"].as_str())
.unwrap_or_else(|| panic!("no note for {path} in {list}: {v}"))
.to_string()
}
#[tokio::test(flavor = "multi_thread")]
async fn pull_copies_every_document_to_its_path() {
let server = Server::start().await;
let (_a, b) = two_checkouts(&server).await;
let out = ok(&b, &["pull", "--json"]).await.json();
assert_eq!(
paths(&out, "written"),
["docs/plan.md", "docs/site", "notes.md"],
"{out}"
);
assert_eq!(read(&b, "docs/plan.md"), "# Plan\none\n");
assert_eq!(read(&b, "docs/site/index.md"), "# Site\n");
assert_eq!(read(&b, "docs/site/style.css"), "body { color: red }\n");
assert_eq!(read(&b, "notes.md"), "# Notes\n");
let ignore = read(&b, ".semora-state/.gitignore");
assert!(
ignore.lines().any(|l| l == "*"),
"the record ignores itself: {ignore}"
);
let again = ok(&b, &["pull"]).await;
assert!(
again.stdout.contains("Nothing new in proj: 3 documents"),
"{}",
again.stdout
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_second_pull_writes_only_what_is_newer() {
let server = Server::start().await;
let (a, b) = two_checkouts(&server).await;
ok(&b, &["pull"]).await;
a.write("docs/plan.md", "# Plan\ntwo\n");
ok(&a, &["push", "docs/plan.md"]).await;
let out = ok(&b, &["pull", "--json"]).await.json();
assert_eq!(paths(&out, "written"), ["docs/plan.md"], "{out}");
assert_eq!(out["written"][0]["rev"], 2);
assert_eq!(paths(&out, "unchanged"), ["docs/site", "notes.md"]);
assert_eq!(read(&b, "docs/plan.md"), "# Plan\ntwo\n");
}
#[tokio::test(flavor = "multi_thread")]
async fn pull_keeps_a_file_changed_here_and_force_takes_the_servers() {
let server = Server::start().await;
let (a, b) = two_checkouts(&server).await;
ok(&b, &["pull"]).await;
b.write("notes.md", "# Notes\nmine\n");
b.write("docs/plan.md", "# Plan\nlocal only\n");
a.write("notes.md", "# Notes\ntheirs\n");
ok(&a, &["push", "notes.md"]).await;
let out = ok(&b, &["pull", "--json"]).await.json();
assert_eq!(paths(&out, "skipped"), ["notes.md"], "{out}");
let why = note_of(&out, "skipped", "notes.md");
assert!(
why.contains("changed here and on the server") && why.contains("r1") && why.contains("r2"),
"{why}"
);
assert_eq!(read(&b, "notes.md"), "# Notes\nmine\n", "not overwritten");
assert!(
note_of(&out, "unchanged", "docs/plan.md").contains("not pushed"),
"{out}"
);
let text = ok(&b, &["pull"]).await.stdout;
assert!(
text.contains("skipped notes.md")
&& text.contains("Changed here, not pushed yet: docs/plan.md"),
"{text}"
);
let forced = ok(&b, &["pull", "--force", "--json"]).await.json();
assert_eq!(paths(&forced, "written"), ["notes.md"], "{forced}");
assert_eq!(read(&b, "notes.md"), "# Notes\ntheirs\n");
assert_eq!(
read(&b, "docs/plan.md"),
"# Plan\nlocal only\n",
"--force takes what is newer on the server, not every file"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_bundle_follows_its_new_revision() {
let server = Server::start().await;
let (a, b) = two_checkouts(&server).await;
ok(&b, &["pull"]).await;
b.write("docs/site/draft.md", "# Not pushed");
std::fs::remove_file(a.path("docs/site/style.css")).unwrap();
a.write("docs/site/extra.md", "# Extra\n");
ok(&a, &["push", "docs/site"]).await;
let out = ok(&b, &["pull", "--json"]).await.json();
assert_eq!(
paths(&out, "skipped"),
["docs/site"],
"a file added here is a change here: {out}"
);
std::fs::remove_file(b.path("docs/site/draft.md")).unwrap();
let out = ok(&b, &["pull", "--json"]).await.json();
assert_eq!(paths(&out, "written"), ["docs/site"], "{out}");
assert_eq!(read(&b, "docs/site/extra.md"), "# Extra\n");
assert!(
!b.path("docs/site/style.css").exists(),
"a file the new revision dropped goes"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_document_deleted_on_the_server_goes_unless_it_changed_here() {
let server = Server::start().await;
let (a, b) = two_checkouts(&server).await;
ok(&b, &["pull"]).await;
b.write("docs/plan.md", "# Plan\nmine\n");
let link = |p: &str| format!("{}/p/proj/{p}", server.url);
for p in ["notes.md", "docs/plan.md", "docs/site"] {
ok(&a, &["rm", &link(p)]).await;
}
let out = ok(&b, &["pull", "--json"]).await.json();
assert_eq!(paths(&out, "deleted"), ["docs/site", "notes.md"], "{out}");
assert_eq!(paths(&out, "skipped"), ["docs/plan.md"]);
assert!(
note_of(&out, "skipped", "docs/plan.md").contains("deleted on the server"),
"{out}"
);
assert!(!b.path("notes.md").exists());
assert!(
!b.path("docs/site").exists(),
"a bundle's folder goes when it is empty"
);
assert_eq!(read(&b, "docs/plan.md"), "# Plan\nmine\n", "kept");
let push = b.run(&["push", "docs/plan.md"]).await;
assert_eq!(push.code, 5, "{}", push.stderr);
assert!(push.stderr.contains("deleted"), "{}", push.stderr);
let forced = ok(&b, &["push", "docs/plan.md", "--force", "--json"])
.await
.json();
assert_eq!(forced["rev"], 1, "a new document at the path");
}
#[tokio::test(flavor = "multi_thread")]
async fn pull_with_a_path_takes_only_what_is_at_it_or_under_it() {
let server = Server::start().await;
let (_a, b) = two_checkouts(&server).await;
let out = ok(&b, &["pull", "docs", "--json"]).await.json();
assert_eq!(
paths(&out, "written"),
["docs/plan.md", "docs/site"],
"{out}"
);
assert!(!b.path("notes.md").exists());
let other = Server::start().await;
let (_a, c) = two_checkouts(&other).await;
let out = ok(&c, &["pull", "docs/site/index.md", "--json"])
.await
.json();
assert_eq!(
paths(&out, "written"),
["docs/site"],
"a path inside a bundle pulls the bundle: {out}"
);
std::fs::create_dir_all(c.path("docs")).unwrap();
let out = run_in(&c, c.path("docs"), &["pull", "--json"]).await;
assert_eq!(out.code, 0, "{}", out.stderr);
assert_eq!(paths(&out.json(), "written"), ["docs/plan.md"]);
assert!(!c.path("notes.md").exists());
}
#[tokio::test(flavor = "multi_thread")]
async fn a_push_over_a_newer_revision_stops_with_exit_5() {
let server = Server::start().await;
let (a, b) = two_checkouts(&server).await;
ok(&b, &["pull"]).await;
a.write("docs/plan.md", "# Plan\nfrom A\n");
ok(&a, &["push", "docs/plan.md"]).await;
b.write("docs/plan.md", "# Plan\nfrom B\n");
let push = b.run(&["push", "docs/plan.md"]).await;
assert_eq!(push.code, 5, "{}\n{}", push.stdout, push.stderr);
for want in [
"r2 on the server, not r1",
"--force",
"semora pull --force docs/plan.md",
"/p/proj/docs/plan.md",
] {
assert!(push.stderr.contains(want), "{want}: {}", push.stderr);
}
let shown = ok(
&a,
&[
"show",
&format!("{}/p/proj/docs/plan.md", server.url),
"--json",
],
)
.await
.json();
assert_eq!(shown["rev"], 2, "nothing was pushed");
let forced = ok(&b, &["push", "docs/plan.md", "--force", "--json"])
.await
.json();
assert_eq!(forced["rev"], 3);
b.write("docs/plan.md", "# Plan\nfrom B, again\n");
let next = ok(&b, &["push", "docs/plan.md", "--json"]).await.json();
assert_eq!(next["rev"], 4, "the forced push was recorded");
a.write("docs/plan.md", "# Plan\nA again\n");
assert_eq!(a.run(&["push", "docs/plan.md"]).await.code, 5);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_delete_from_this_folder_lets_the_next_push_make_it_again() {
let server = Server::start().await;
let (_a, b) = two_checkouts(&server).await;
ok(&b, &["pull"]).await;
ok(&b, &["rm", &format!("{}/p/proj/notes.md", server.url)]).await;
let out = ok(&b, &["push", "notes.md", "--json"]).await.json();
assert_eq!(out["rev"], 1, "a new document: {out}");
}
#[tokio::test(flavor = "multi_thread")]
async fn files_already_here_are_recorded_when_they_match_and_skipped_when_not() {
let server = Server::start().await;
let (_a, b) = two_checkouts(&server).await;
b.write("notes.md", "# Notes\n");
b.write("docs/plan.md", "# Plan\nwritten here first\n");
let out = ok(&b, &["pull", "--json"]).await.json();
assert_eq!(paths(&out, "written"), ["docs/site"], "{out}");
assert_eq!(paths(&out, "unchanged"), ["notes.md"]);
assert_eq!(paths(&out, "skipped"), ["docs/plan.md"]);
assert!(
note_of(&out, "skipped", "docs/plan.md").contains("never pulled"),
"{out}"
);
assert_eq!(read(&b, "docs/plan.md"), "# Plan\nwritten here first\n");
let status = ok(&b, &["status", "--json"]).await.json();
let states: BTreeMap<String, String> = status["documents"]
.as_array()
.unwrap()
.iter()
.map(|d| {
(
d["path"].as_str().unwrap().to_string(),
d["state"].as_str().unwrap().to_string(),
)
})
.collect();
assert_eq!(states["notes.md"], "clean", "{status}");
assert_eq!(states["docs/plan.md"], "untracked", "{status}");
}
#[tokio::test(flavor = "multi_thread")]
async fn status_says_where_each_document_stands() {
let server = Server::start().await;
let (a, b) = two_checkouts(&server).await;
a.write("docs/both.md", "# Both\n");
ok(&a, &["push", "docs/both.md"]).await;
ok(&b, &["pull"]).await;
let clean = ok(&b, &["status"]).await.stdout;
assert!(
clean.contains("Everything here matches the server (4 documents)"),
"{clean}"
);
b.write("notes.md", "# Notes\nhere\n");
a.write("docs/plan.md", "# Plan\nthere\n");
ok(&a, &["push", "docs/plan.md"]).await;
a.write("docs/new.md", "# New\n");
ok(&a, &["push", "docs/new.md"]).await;
ok(&a, &["rm", &format!("{}/p/proj/docs/site", server.url)]).await;
b.write("docs/both.md", "# Both\nhere\n");
a.write("docs/both.md", "# Both\nthere\n");
ok(&a, &["push", "docs/both.md"]).await;
let status = ok(&b, &["status", "--json"]).await.json();
let states: BTreeMap<String, (String, Value, Value)> = status["documents"]
.as_array()
.unwrap()
.iter()
.map(|d| {
(
d["path"].as_str().unwrap().to_string(),
(
d["state"].as_str().unwrap().to_string(),
d["here"].clone(),
d["server"].clone(),
),
)
})
.collect();
let state = |p: &str| states[p].0.as_str();
assert_eq!(state("notes.md"), "modified", "{status}");
assert_eq!(state("docs/plan.md"), "behind");
assert_eq!(
(&states["docs/plan.md"].1, &states["docs/plan.md"].2),
(&Value::from(1), &Value::from(2))
);
assert_eq!(state("docs/new.md"), "missing");
assert_eq!(state("docs/site"), "deleted");
assert_eq!(state("docs/both.md"), "diverged");
let text = ok(&b, &["status"]).await.stdout;
for want in [
"changed here notes.md",
"newer on the server docs/plan.md (r1 here, r2 on the server)",
"not here docs/new.md",
"deleted on the server docs/site",
"changed on both docs/both.md",
] {
let squeezed = |s: &str| s.split_whitespace().collect::<Vec<_>>().join(" ");
assert!(squeezed(&text).contains(&squeezed(want)), "{want}:\n{text}");
}
let narrowed = ok(&b, &["status", "docs/plan.md", "--json"]).await.json();
assert_eq!(
narrowed["documents"].as_array().unwrap().len(),
1,
"{narrowed}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn pull_outside_a_project_says_how_to_make_one() {
let server = Server::start().await;
let cli = Cli::new(&server);
let out = cli.run(&["pull"]).await;
assert_eq!(out.code, 1);
assert!(out.stderr.contains("semora project init"), "{}", out.stderr);
}