mod common;
use std::path::PathBuf;
use common::*;
use semora_proto::Scope;
use semora_server::auth::{apikey, users};
#[tokio::test(flavor = "multi_thread")]
async fn project_init_creates_the_project_and_writes_dot_semora() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("My Docs_v2/readme.md", "# x");
let out = cli.run(&["project", "init", "My Docs_v2"]).await;
assert_eq!(out.code, 0, "{}", out.stderr);
assert!(out.stdout.contains("Created project"), "{}", out.stdout);
assert!(out.stdout.contains("Commit it"), "{}", out.stdout);
assert!(
out.stdout.contains(&format!("{}/p/my-docs-v2", server.url)),
"{}",
out.stdout
);
let text = std::fs::read_to_string(cli.path("My Docs_v2/.semora")).unwrap();
assert!(text.contains("project = \"my-docs-v2\""), "{text}");
assert!(
text.contains(&format!("server = \"{}\"", id_of(&server).await)),
"the server by its id: {text}"
);
assert!(
!text.contains(&server.url) && !text.contains("127.0.0.1"),
"and not by its address: {text}"
);
let list = cli.run(&["project", "ls", "--json"]).await.json();
assert_eq!(list[0]["slug"], "my-docs-v2");
assert_eq!(
list[0]["name"], "My Docs_v2",
"the display name is the directory name"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn project_init_links_an_existing_project_and_name_picks_the_id() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("one/x.md", "# x");
cli.write("two/x.md", "# x");
assert_eq!(
cli.run(&["project", "init", "one", "--name", "shared"])
.await
.code,
0
);
let out = cli
.run(&["project", "init", "two", "--name", "shared"])
.await;
assert_eq!(out.code, 0, "{}", out.stderr);
assert!(
out.stdout.contains("Linked to your existing project"),
"{}",
out.stdout
);
assert!(
std::fs::read_to_string(cli.path("two/.semora"))
.unwrap()
.contains("project = \"shared\"")
);
let bad = cli
.run(&["project", "init", "one", "--name", "Not Valid"])
.await;
assert_ne!(bad.code, 0);
assert!(bad.stderr.contains("lowercase"), "{}", bad.stderr);
}
#[tokio::test(flavor = "multi_thread")]
async fn project_init_for_someone_elses_id_suggests_name() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("taken/x.md", "# x");
assert_eq!(cli.run(&["project", "init", "taken"]).await.code, 0);
let bob = users::create(
&server.state.db,
"bob@example.com",
"Bob",
"correct horse battery",
false,
)
.await
.unwrap();
let (_, bob_key) = apikey::create(&server.state, bob.id, "bob", &Scope::all(), None, "web")
.await
.unwrap();
let mut bob_cli = Cli::new(&server);
bob_cli.key = Some(bob_key);
bob_cli.write("taken/x.md", "# x");
let out = bob_cli.run(&["project", "init", "taken"]).await;
assert_ne!(out.code, 0);
assert!(
out.stderr.contains("belongs to someone else") && out.stderr.contains("--name"),
"{}",
out.stderr
);
assert!(
!bob_cli.path("taken/.semora").exists(),
"nothing is written"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn project_init_twice_is_quiet_and_a_different_id_is_refused() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("proj/x.md", "# x");
assert_eq!(cli.run(&["project", "init", "proj"]).await.code, 0);
let again = cli.run(&["project", "init", "proj"]).await;
assert_eq!(again.code, 0, "{}", again.stderr);
assert!(
!again.stdout.contains("Commit it"),
"the file was already right: {}",
again.stdout
);
let other = cli
.run(&["project", "init", "proj", "--name", "elsewhere"])
.await;
assert_ne!(other.code, 0);
assert!(
other.stderr.contains("already links this folder"),
"{}",
other.stderr
);
assert!(
cli.run(&["project", "ls", "--json"])
.await
.json()
.as_array()
.unwrap()
.len()
== 1,
"nothing was sent for the refused one"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn project_init_refuses_a_dot_semora_for_another_server() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("proj/x.md", "# x");
cli.write(
"proj/.semora",
"server = \"https://other.example.com\"\nproject = \"proj\"\n",
);
let out = cli.run(&["project", "init", "proj"]).await;
assert_ne!(out.code, 0);
assert!(
out.stderr.contains("https://other.example.com") && out.stderr.contains("--server"),
"{}",
out.stderr
);
assert!(
cli.run(&["project", "ls", "--json"])
.await
.json()
.as_array()
.unwrap()
.is_empty(),
"nothing was sent"
);
assert!(
std::fs::read_to_string(cli.path("proj/.semora"))
.unwrap()
.contains("other.example.com"),
"the file is untouched"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn project_init_recreates_a_project_that_is_gone_on_the_server() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("proj/x.md", "# x");
cli.write(
"proj/.semora",
format!("server = \"{}\"\nproject = \"ghost\"\n", server.url),
);
let out = cli.run(&["project", "init", "proj"]).await;
assert_eq!(out.code, 0, "{}", out.stderr);
assert!(
out.stdout.contains("Created project") && out.stdout.contains("(ghost)"),
"{}",
out.stdout
);
assert!(
!out.stdout.contains("Commit it"),
"the file was already there: {}",
out.stdout
);
}
#[tokio::test(flavor = "multi_thread")]
async fn project_init_in_a_checkout_named_otherwise_uses_the_id_in_dot_semora() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("semora/x.md", "# x");
assert_eq!(cli.run(&["project", "init", "semora"]).await.code, 0);
cli.write("worktrees/projects/x.md", "# x");
cli.write(
"worktrees/projects/.semora",
format!("server = \"{}\"\nproject = \"semora\"\n", server.url),
);
let out = cli.run(&["project", "init", "worktrees/projects"]).await;
assert_eq!(out.code, 0, "{}", out.stderr);
assert!(
out.stdout.contains("Linked to your existing project"),
"{}",
out.stdout
);
let projects = cli.run(&["project", "ls", "--json"]).await.json();
assert_eq!(
projects.as_array().unwrap().len(),
1,
"no second project: {projects}"
);
let other = cli
.run(&[
"project",
"init",
"worktrees/projects",
"--name",
"projects",
])
.await;
assert_ne!(other.code, 0);
assert!(
other.stderr.contains("already links this folder"),
"{}",
other.stderr
);
}
#[tokio::test(flavor = "multi_thread")]
async fn project_ls_prints_one_line_per_project() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("alpha/x.md", "# x");
cli.write("beta/x.md", "# x");
cli.run(&["project", "init", "alpha"]).await;
cli.run(&["project", "init", "beta"]).await;
let out = cli.run(&["project", "ls"]).await;
assert_eq!(out.code, 0, "{}", out.stderr);
assert!(
out.stdout.contains("alpha") && out.stdout.contains("beta"),
"{}",
out.stdout
);
assert!(out.stdout.contains("0 docs"), "{}", out.stdout);
}
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()
}
async fn id_of(server: &Server) -> String {
semora_server::db::server_id(&server.state.db)
.await
.unwrap()
}
async fn seed_project(cli: &Cli, slug: &str) {
let dir = format!("seed-{slug}");
cli.write(&format!("{dir}/x.md"), "# x");
let out = cli.run(&["project", "init", &dir, "--name", slug]).await;
assert_eq!(out.code, 0, "{}", out.stderr);
}
const OTHER_ID: &str = "0123456789abcdef0123456789abcdef";
#[tokio::test(flavor = "multi_thread")]
async fn project_init_refuses_an_id_for_another_server() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("proj/x.md", "# x");
cli.write(
"proj/.semora",
format!("server = \"{OTHER_ID}\"\nproject = \"proj\"\n"),
);
let out = cli.run(&["project", "init", "proj"]).await;
assert_ne!(out.code, 0);
assert!(out.stderr.contains("semora project link"), "{}", out.stderr);
assert!(
cli.run(&["project", "ls", "--json"])
.await
.json()
.as_array()
.unwrap()
.is_empty(),
"nothing was sent"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_legacy_dot_semora_for_this_server_moves_to_the_id() {
let server = Server::start().await;
let cli = Cli::new(&server);
seed_project(&cli, "proj").await;
cli.write("docs/plan.md", "# P");
cli.write(
".semora",
format!(
"# keep me\r\nserver = \"{}\"\r\nproject = \"proj\"\r\n",
server.url
),
);
let out = cli.run(&["push", "docs/plan.md"]).await;
assert_eq!(out.code, 0, "{}", out.stderr);
assert!(
out.stderr.contains("names its server by id") && out.stderr.contains("Commit it"),
"{}",
out.stderr
);
assert_eq!(
std::fs::read_to_string(cli.path(".semora")).unwrap(),
format!(
"# keep me\r\nserver = \"{}\"\r\nproject = \"proj\"\r\n",
id_of(&server).await
)
);
let again = cli.run(&["push", "docs/plan.md"]).await;
assert!(
!again.stderr.contains("names its server by id"),
"said once: {}",
again.stderr
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_legacy_file_that_cannot_be_rewritten_still_pushes() {
let server = Server::start().await;
let cli = Cli::new(&server);
seed_project(&cli, "proj").await;
cli.write("docs/plan.md", "# P");
let text = format!("server = \"{}\"\nproject = \"proj\"\n", server.url);
let file = cli.write(".semora", &text);
let mut perms = std::fs::metadata(&file).unwrap().permissions();
perms.set_readonly(true);
std::fs::set_permissions(&file, perms.clone()).unwrap();
let writable = std::fs::OpenOptions::new().write(true).open(&file).is_ok();
let out = if writable {
None
} else {
Some(cli.run(&["push", "docs/plan.md"]).await)
};
#[allow(clippy::permissions_set_readonly_false)]
perms.set_readonly(false);
std::fs::set_permissions(&file, perms).unwrap();
let Some(out) = out else {
eprintln!("skipped: this user can write a read-only file");
return;
};
assert_eq!(out.code, 0, "{}", out.stderr);
assert!(
out.stderr.contains("Change the `server` line"),
"{}",
out.stderr
);
assert_eq!(std::fs::read_to_string(&file).unwrap(), text);
}
#[tokio::test(flavor = "multi_thread")]
async fn an_id_for_another_server_stops_with_three_ways_out() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("docs/plan.md", "# P");
cli.write(
".semora",
format!("server = \"{OTHER_ID}\"\nproject = \"proj\"\n"),
);
let out = cli.run(&["push", "docs/plan.md"]).await;
assert_eq!(out.code, 1, "{}", out.stderr);
for part in [
"another Semora server",
"semora login --server ADDRESS",
"semora project link",
"semora project link --rewrite",
"--id or --new",
] {
assert!(out.stderr.contains(part), "{part}: {}", out.stderr);
}
assert_eq!(
out.stderr.matches(&server.url).count(),
1,
"only this CLI's own server is named: {}",
out.stderr
);
let ls = cli.run(&["ls", "--json"]).await.json();
assert_eq!(ls["docs"].as_array().unwrap().len(), 0, "nothing was sent");
}
#[tokio::test(flavor = "multi_thread")]
async fn an_id_in_upper_case_is_still_an_id() {
let server = Server::start().await;
let cli = Cli::new(&server);
seed_project(&cli, "proj").await;
cli.write("docs/plan.md", "# P");
cli.write(
".semora",
format!(
"server = \" {} \"\nproject = \"proj\"\n",
id_of(&server).await.to_uppercase()
),
);
let out = cli.run(&["push", "docs/plan.md"]).await;
assert_eq!(out.code, 0, "{}", out.stderr);
}
fn dot_semora(cli: &Cli, server: &Server, project: &str) {
cli.write(
".semora",
format!("server = \"{}\"\nproject = \"{project}\"\n", server.url),
);
}
fn with_machine(cli: &mut Cli, name: &str) {
cli.extra_env = vec![
("COMPUTERNAME".into(), name.into()),
("HOSTNAME".into(), name.into()),
];
}
#[tokio::test(flavor = "multi_thread")]
async fn a_project_push_prints_the_path_link_first() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("docs/plan.md", "# Plan");
assert_eq!(
cli.run(&["project", "init", ".", "--name", "semora"])
.await
.code,
0
);
let out = cli.run(&["push", "docs/plan.md"]).await;
assert_eq!(out.code, 0, "{}", out.stderr);
let path_at = out
.stdout
.find(&format!("{}/p/semora/docs/plan.md", server.url))
.unwrap_or_else(|| panic!("{}", out.stdout));
let url_at = out.stdout.find(&format!("{}/d/", server.url)).unwrap();
assert!(
path_at < url_at,
"the path link comes first: {}",
out.stdout
);
cli.write("docs/plan.md", "# Plan v2");
let again = cli.run(&["push", "docs/plan.md", "--json"]).await.json();
assert_eq!(again["rev"], 2, "the same key is the same document");
assert_eq!(again["unchanged"], false);
let same = cli.run(&["push", "docs/plan.md", "--json"]).await.json();
assert_eq!(same["id"], again["id"]);
assert_eq!(same["rev"], 2, "nothing changed, so no new revision");
assert_eq!(same["unchanged"], true);
}
#[tokio::test(flavor = "multi_thread")]
async fn an_unchanged_push_says_so_and_a_message_is_not_recorded() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("docs/plan.md", "# Plan");
assert_eq!(
cli.run(&["project", "init", ".", "--name", "semora"])
.await
.code,
0
);
let note = "no revision was made and the message was not recorded";
let first = cli
.run(&["push", "docs/plan.md", "--message", "first"])
.await;
assert_eq!(first.code, 0, "{}", first.stderr);
assert!(
first.stdout.starts_with("Pushed \"Plan\" · r1"),
"{}",
first.stdout
);
assert!(!first.stderr.contains(note), "{}", first.stderr);
let same = cli
.run(&["push", "docs/plan.md", "--message", "again"])
.await;
assert_eq!(same.code, 0, "{}", same.stderr);
assert!(
same.stdout
.starts_with("Nothing changed: \"Plan\" stays at r1"),
"the output says nothing changed and names the current revision: {}",
same.stdout
);
assert!(
same.stderr
.contains(&format!("the content was unchanged, so {note}")),
"{}",
same.stderr
);
let quiet = cli.run(&["push", "docs/plan.md"]).await;
assert!(
quiet.stdout.starts_with("Nothing changed"),
"{}",
quiet.stdout
);
assert!(!quiet.stderr.contains(note), "{}", quiet.stderr);
let json = cli
.run(&["push", "docs/plan.md", "--message", "m", "--json"])
.await;
assert_eq!(json.json()["unchanged"], true);
assert!(json.stderr.contains(note), "{}", json.stderr);
cli.write("docs/plan.md", "# Plan 2");
let changed = cli
.run(&["push", "docs/plan.md", "--message", "second"])
.await;
assert!(
changed.stdout.starts_with("Pushed \"Plan 2\" · r2"),
"{}",
changed.stdout
);
assert!(!changed.stderr.contains(note), "{}", changed.stderr);
}
#[tokio::test(flavor = "multi_thread")]
async fn dot_semora_is_found_from_the_pushed_path_not_the_current_directory() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("proj/docs/x.md", "# X");
cli.write("elsewhere/y.md", "# Y");
assert_eq!(
cli.run(&["project", "init", "proj", "--name", "semora"])
.await
.code,
0
);
let out = run_in(
&cli,
cli.path("elsewhere"),
&["push", "../proj/docs/x.md", "--json"],
)
.await;
assert_eq!(out.code, 0, "{}", out.stderr);
let v = out.json();
assert_eq!(
(v["project"].as_str(), v["path"].as_str()),
(Some("semora"), Some("docs/x.md"))
);
let out = run_in(
&cli,
cli.path("proj"),
&["push", "../elsewhere/y.md", "--json"],
)
.await;
assert_eq!(out.code, 0, "{}", out.stderr);
assert!(
out.json().get("project").is_none(),
"outside the project nothing changes"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn two_checkouts_update_one_document() {
let server = Server::start().await;
let mut a = Cli::new(&server);
with_machine(&mut a, "BoxA");
a.write("docs/plan.md", "# Plan A");
a.write(".git/HEAD", "ref: refs/heads/feat/a\n");
assert_eq!(
a.run(&["project", "init", ".", "--name", "semora"])
.await
.code,
0
);
let first = a.run(&["push", "docs/plan.md", "--json"]).await.json();
let mut b = Cli::new(&server);
with_machine(&mut b, "BoxB");
b.write("docs/plan.md", "# Plan B");
b.write(".git/HEAD", "ref: refs/heads/feat/b\n");
dot_semora(&b, &server, "semora");
let second = b.run(&["push", "docs/plan.md", "--json"]).await.json();
assert_eq!(second["id"], first["id"], "one path, one document");
assert_eq!(second["rev"], 2);
let shown = a
.run(&["show", first["id"].as_str().unwrap(), "--json"])
.await
.json();
let origins: Vec<&str> = shown["revisions"]
.as_array()
.unwrap()
.iter()
.map(|r| r["origin"].as_str().unwrap())
.collect();
assert_eq!(
origins,
["boxb \u{b7} feat/b", "boxa \u{b7} feat/a"],
"newest first"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn dot_semora_for_another_server_stops_the_push() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("docs/plan.md", "# P");
cli.write(
".semora",
"server = \"https://other.example.com\"\nproject = \"semora\"\n",
);
let out = cli.run(&["push", "docs/plan.md"]).await;
assert_eq!(out.code, 1, "{}", out.stderr);
assert!(
out.stderr.contains("https://other.example.com") && out.stderr.contains(&server.url),
"{}",
out.stderr
);
assert!(
out.stderr.contains("--server") && out.stderr.contains("--id or --new"),
"push adds its own way out: {}",
out.stderr
);
let ls = cli.run(&["ls", "--json"]).await.json();
assert_eq!(ls["docs"].as_array().unwrap().len(), 0, "nothing was sent");
}
#[tokio::test(flavor = "multi_thread")]
async fn an_old_remembered_document_is_carried_into_the_project() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("docs/plan.md", "# Old");
let old = cli.run(&["push", "docs/plan.md", "--json"]).await.json();
assert_eq!(cli.cache().len(), 1);
assert_eq!(
cli.run(&["project", "init", ".", "--name", "semora"])
.await
.code,
0
);
cli.write("docs/plan.md", "# New");
let v = cli.run(&["push", "docs/plan.md", "--json"]).await.json();
assert_eq!(v["id"], old["id"], "links already posted keep working");
assert_eq!(v["rev"], 2);
assert_eq!(v["path"], "docs/plan.md");
assert_eq!(
cli.cache().len(),
1,
"document.cache is not written for keyed pushes"
);
assert_eq!(cli.cache()[0][2], old["id"].as_str().unwrap());
let shown = cli
.run(&["show", old["id"].as_str().unwrap(), "--json"])
.await
.json();
assert_eq!(shown["project"], "semora");
}
#[tokio::test(flavor = "multi_thread")]
async fn id_and_new_inside_a_project_push_outside_it_and_say_so() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("docs/plan.md", "# P");
assert_eq!(
cli.run(&["project", "init", ".", "--name", "semora"])
.await
.code,
0
);
let keyed = cli.run(&["push", "docs/plan.md", "--json"]).await.json();
let id = keyed["id"].as_str().unwrap();
let out = cli.run(&["push", "docs/plan.md", "--new", "--json"]).await;
assert_eq!(out.code, 0, "{}", out.stderr);
assert!(
out.stderr
.contains("note: --new makes a document outside project semora"),
"{}",
out.stderr
);
let fresh = out.json();
assert_ne!(fresh["id"], keyed["id"]);
assert!(fresh.get("project").is_none(), "{fresh}");
let out = cli
.run(&["push", "docs/plan.md", "--id", id, "--json"])
.await;
assert_eq!(out.code, 0, "{}", out.stderr);
assert!(
out.stderr
.contains("note: --id pushes to that document directly, not into project semora"),
"{}",
out.stderr
);
assert_eq!(out.json()["id"], id);
}
#[tokio::test(flavor = "multi_thread")]
async fn pushing_the_project_root_is_refused() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("readme.md", "# Root");
cli.write("notes.xyz", "a file type the server does not serve");
assert_eq!(
cli.run(&["project", "init", ".", "--name", "semora"])
.await
.code,
0
);
let out = cli.run(&["push", "."]).await;
assert_eq!(out.code, 1);
assert!(
out.stderr.contains("not the project root"),
"{}",
out.stderr
);
assert!(
!out.stderr.contains("skipped"),
"the folder was not packed first: {}",
out.stderr
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_missing_project_gives_a_clear_push_error() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("docs/plan.md", "# P");
dot_semora(&cli, &server, "ghost");
let out = cli.run(&["push", "docs/plan.md"]).await;
assert_eq!(out.code, 4, "{}", out.stderr);
assert!(
out.stderr.contains("ghost") && out.stderr.contains("semora project init"),
"{}",
out.stderr
);
assert!(
out.stderr.contains("no longer have access"),
"{}",
out.stderr
);
}
async fn old_server() -> String {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, axum::Router::new()).await.unwrap();
});
format!("http://{addr}")
}
#[tokio::test(flavor = "multi_thread")]
async fn project_commands_on_an_older_server_say_so() {
let server = Server::start().await;
let mut cli = Cli::new(&server);
cli.url = old_server().await;
cli.write("proj/x.md", "# x");
cli.write(
"legacy/.semora",
format!("server = \"{}\"\nproject = \"legacy\"\n", cli.url),
);
cli.write("legacy/x.md", "# x");
for args in [
&["project", "init", "proj"][..],
&["project", "ls"][..],
&["push", "legacy/x.md"][..],
] {
let out = cli.run(args).await;
assert_ne!(out.code, 0, "{args:?}");
assert!(
out.stderr.contains("older than this CLI"),
"{args:?}: {}",
out.stderr
);
}
assert!(!cli.path("proj/.semora").exists(), "nothing is written");
}
#[tokio::test(flavor = "multi_thread")]
async fn ls_project_lists_only_that_project() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("docs/a.md", "# A");
cli.write("b.md", "# B");
assert_eq!(
cli.run(&["project", "init", ".", "--name", "semora"])
.await
.code,
0
);
let a = cli.run(&["push", "docs/a.md", "--json"]).await.json();
cli.run(&["push", "b.md", "--new", "--json"]).await;
let v = cli
.run(&["ls", "--project", "semora", "--json"])
.await
.json();
let ids: Vec<&str> = v["docs"]
.as_array()
.unwrap()
.iter()
.map(|d| d["id"].as_str().unwrap())
.collect();
assert_eq!(ids, [a["id"].as_str().unwrap()]);
let text = cli.run(&["ls", "--project", "semora"]).await;
assert!(text.stdout.contains("semora:docs/a.md"), "{}", text.stdout);
let all = cli.run(&["ls"]).await;
assert!(
all.stdout.contains("semora:docs/a.md"),
"project documents show their key everywhere: {}",
all.stdout
);
let nope = cli.run(&["ls", "--project", "nope"]).await;
assert_eq!(nope.code, 4, "{}", nope.stderr);
assert!(nope.stderr.contains("no project `nope`"), "{}", nope.stderr);
}
#[tokio::test(flavor = "multi_thread")]
async fn show_prints_project_path_link_and_origin() {
let server = Server::start().await;
let mut cli = Cli::new(&server);
with_machine(&mut cli, "BoxA");
cli.write("docs/a.md", "# A");
cli.write(".git/HEAD", "ref: refs/heads/feat/a\n");
assert_eq!(
cli.run(&["project", "init", ".", "--name", "semora"])
.await
.code,
0
);
let a = cli.run(&["push", "docs/a.md", "--json"]).await.json();
let out = cli.run(&["show", a["id"].as_str().unwrap()]).await;
assert_eq!(out.code, 0, "{}", out.stderr);
assert!(out.stdout.contains(" proj semora"), "{}", out.stdout);
assert!(out.stdout.contains(" path docs/a.md"), "{}", out.stdout);
assert!(
out.stdout
.contains(&format!(" link {}/p/semora/docs/a.md", server.url)),
"{}",
out.stdout
);
assert!(
out.stdout.contains("from boxa \u{b7} feat/a"),
"{}",
out.stdout
);
}
#[tokio::test(flavor = "multi_thread")]
async fn path_links_work_where_a_document_is_expected() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("docs/plan.md", "# Plan\nbody");
cli.write("site/index.md", "# Site");
cli.write("site/page.md", "# Page");
assert_eq!(
cli.run(&["project", "init", ".", "--name", "semora"])
.await
.code,
0
);
let plan = cli.run(&["push", "docs/plan.md", "--json"]).await.json();
let site = cli.run(&["push", "site", "--json"]).await.json();
let link = plan["path_url"].as_str().unwrap().to_string();
let shown = cli.run(&["show", &link, "--json"]).await.json();
assert_eq!(shown["id"], plan["id"]);
let got = cli.run(&["get", &link]).await;
assert_eq!(got.stdout, "# Plan\nbody");
assert_eq!(cli.run(&["tag", &link, "+x"]).await.code, 0);
let page_link = format!("{}/p/semora/site/page.md", server.url);
assert_eq!(
cli.run(&["show", &page_link, "--json"]).await.json()["id"],
site["id"],
"a page inside a bundle names the bundle"
);
cli.write("other.md", "# Other");
let out = cli
.run(&["push", "other.md", "--id", &link, "--json"])
.await;
assert_eq!(out.code, 0, "{}", out.stderr);
assert_eq!(out.json()["id"], plan["id"], "--id takes a path link too");
let wrong = cli
.run(&["show", "https://other.example.com/p/semora/docs/plan.md"])
.await;
assert_eq!(wrong.code, 1);
assert!(
wrong.stderr.contains("other.example.com") && wrong.stderr.contains(&server.url),
"{}",
wrong.stderr
);
let page = cli
.run(&["show", &format!("{}/p/semora", server.url)])
.await;
assert_eq!(page.code, 1);
assert!(page.stderr.contains("project page"), "{}", page.stderr);
let missing = cli
.run(&["show", &format!("{}/p/semora/nope.md", server.url)])
.await;
assert_eq!(missing.code, 4, "{}", missing.stderr);
assert!(
missing.stderr.contains("nothing at `nope.md` in semora"),
"{}",
missing.stderr
);
let folder = cli
.run(&["show", &format!("{}/p/semora/docs", server.url)])
.await;
assert_eq!(folder.code, 4, "{}", folder.stderr);
assert!(
folder.stderr.contains("`docs` is a folder in semora"),
"{}",
folder.stderr
);
}
#[tokio::test(flavor = "multi_thread")]
async fn watch_with_a_dot_semora_for_another_server_names_only_its_own_flag() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("docs/plan.md", "# P");
cli.write(
".semora",
"server = \"https://other.example.com\"\nproject = \"semora\"\n",
);
let out = cli.run(&["watch", "docs/plan.md"]).await;
assert_eq!(out.code, 1, "{}", out.stderr);
assert!(
out.stderr.contains("https://other.example.com") && out.stderr.contains("--id"),
"{}",
out.stderr
);
assert!(
!out.stderr.contains("--new"),
"watch has no --new: {}",
out.stderr
);
}
async fn old_server_with_documents() -> String {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let app = axum::Router::new().route(
"/api/v1/docs",
axum::routing::get(|| async {
axum::Json(serde_json::json!({"docs": [{
"id": "k3F9xQ2mLp", "title": "Not in a project", "kind": "md", "tags": [],
"rev": 1, "revision_count": 1, "url": "http://x/d/k3F9xQ2mLp/",
"created_at": "2026-10-01T12:00:00Z", "updated_at": "2026-10-01T12:00:00Z"
}]}))
}),
);
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
format!("http://{addr}")
}
#[tokio::test(flavor = "multi_thread")]
async fn ls_project_on_an_older_server_is_refused() {
let server = Server::start().await;
let mut cli = Cli::new(&server);
cli.url = old_server_with_documents().await;
for args in [
&["ls", "--project", "semora"][..],
&["ls", "--project", "semora", "--json"][..],
] {
let out = cli.run(args).await;
assert_ne!(out.code, 0, "{args:?}: {}", out.stdout);
assert!(
out.stderr.contains("older than this CLI"),
"{args:?}: {}",
out.stderr
);
assert!(
!out.stdout.contains("Not in a project"),
"{args:?}: nothing is listed: {}",
out.stdout
);
}
assert_eq!(cli.run(&["ls"]).await.code, 0);
}
#[tokio::test(flavor = "multi_thread")]
async fn project_init_in_a_collaborators_project_needs_join() {
let server = Server::start().await;
let mallory = users::create(
&server.state.db,
"mallory@example.com",
"Mallory",
"correct horse battery",
false,
)
.await
.unwrap();
let project = semora_server::projects::insert_project(
&server.state.db,
mallory.id,
"acme-api",
"acme-api",
)
.await
.unwrap()
.unwrap();
semora_server::collab::add_collaborator(
&server.state.db,
project.id,
server.user_id,
semora_proto::Level::Editor,
Some(mallory.id),
)
.await
.unwrap();
let cli = Cli::new(&server);
cli.write("acme-api/x.md", "# x");
let out = cli.run(&["project", "init", "acme-api"]).await;
assert_ne!(out.code, 0, "{}", out.stdout);
assert!(
out.stderr
.contains("Project `acme-api` belongs to Mallory, and you are an Editor in it.")
&& out.stderr.contains("semora project init --join"),
"{}",
out.stderr
);
assert!(
!cli.path("acme-api/.semora").exists(),
"nothing is written without --join"
);
let out = cli.run(&["project", "init", "acme-api", "--join"]).await;
assert_eq!(out.code, 0, "{}", out.stderr);
assert!(out.stdout.contains("Joined"), "{}", out.stdout);
assert!(cli.path("acme-api/.semora").exists());
}
async fn give_new_id(server: &Server, old: &str, new: &str) {
let row = semora_server::projects::find_by_slug(&server.state.db, old)
.await
.unwrap()
.unwrap();
semora_server::projects::rename(&server.state.db, &row, &row.name, Some(new))
.await
.unwrap();
}
#[tokio::test(flavor = "multi_thread")]
async fn a_push_follows_a_new_project_id_and_updates_dot_semora() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("docs/plan.md", "# Plan");
cli.write(
".semora",
format!(
"# keep me\r\nserver = \"{}\"\r\nproject = \"old-id\"\r\n",
server.url
),
);
let first = cli.run(&["project", "init", "."]).await;
assert_eq!(first.code, 0, "{}", first.stderr);
let pushed = cli.run(&["push", "docs/plan.md", "--json"]).await.json();
give_new_id(&server, "old-id", "new-id").await;
cli.write("docs/plan.md", "# Plan v2");
let out = cli.run(&["push", "docs/plan.md", "--json"]).await;
assert_eq!(out.code, 0, "{}", out.stderr);
assert!(
out.stderr.contains("project `old-id` is now `new-id`") && out.stderr.contains("commit it"),
"{}",
out.stderr
);
let v = out.json();
assert_eq!(v["id"], pushed["id"]);
assert_eq!(v["rev"], 2);
assert_eq!(v["project"], "new-id");
let text = std::fs::read_to_string(cli.path(".semora")).unwrap();
assert_eq!(
text,
format!(
"# keep me\r\nserver = \"{}\"\r\nproject = \"new-id\"\r\n",
id_of(&server).await
)
);
let record = std::fs::read_to_string(cli.path(".semora-state/checkout.json")).unwrap();
assert!(record.contains("\"project\": \"new-id\""), "{record}");
assert!(record.contains("docs/plan.md"), "{record}");
let again = cli.run(&["push", "docs/plan.md", "--json"]).await;
assert!(
!again.stderr.contains("is now"),
"said once: {}",
again.stderr
);
}
#[tokio::test(flavor = "multi_thread")]
async fn after_a_hard_break_the_record_still_finds_the_project() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("docs/plan.md", "# Plan");
assert_eq!(
cli.run(&["project", "init", ".", "--name", "old-id"])
.await
.code,
0
);
assert_eq!(cli.run(&["push", "docs/plan.md"]).await.code, 0);
semora_server::projects::aliases::set_policy(
&server.state.db,
semora_server::projects::aliases::Policy::None,
)
.await
.unwrap();
give_new_id(&server, "old-id", "new-id").await;
let out = cli.run(&["status"]).await;
assert_eq!(out.code, 0, "{}", out.stderr);
assert!(out.stderr.contains("is now `new-id`"), "{}", out.stderr);
assert!(
std::fs::read_to_string(cli.path(".semora"))
.unwrap()
.contains("project = \"new-id\"")
);
}
#[tokio::test(flavor = "multi_thread")]
async fn project_init_with_an_old_id_writes_the_new_one() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("a/x.md", "# x");
cli.write("b/x.md", "# x");
assert_eq!(
cli.run(&["project", "init", "a", "--name", "old-id"])
.await
.code,
0
);
give_new_id(&server, "old-id", "new-id").await;
let out = cli.run(&["project", "init", "b", "--name", "old-id"]).await;
assert_eq!(out.code, 0, "{}", out.stderr);
assert!(out.stdout.contains("(new-id)"), "{}", out.stdout);
assert!(
std::fs::read_to_string(cli.path("b/.semora"))
.unwrap()
.contains("project = \"new-id\"")
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_checkout_that_gets_the_new_id_from_git_keeps_its_record() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("a/docs/plan.md", "# Plan");
cli.write("b/docs/plan.md", "# Plan");
for dir in ["a", "b"] {
let out = cli.run(&["project", "init", dir, "--name", "old-id"]).await;
assert_eq!(out.code, 0, "{}", out.stderr);
}
assert_eq!(cli.run(&["push", "a/docs/plan.md"]).await.code, 0);
assert_eq!(cli.run(&["pull", "b"]).await.code, 0);
give_new_id(&server, "old-id", "new-id").await;
assert_eq!(cli.run(&["push", "a/docs/plan.md"]).await.code, 0);
std::fs::copy(cli.path("a/.semora"), cli.path("b/.semora")).unwrap();
let out = cli.run(&["status", "b", "--json"]).await;
assert_eq!(out.code, 0, "{}", out.stderr);
let record = std::fs::read_to_string(cli.path("b/.semora-state/checkout.json")).unwrap();
assert!(record.contains("\"project\": \"new-id\""), "{record}");
assert!(
record.contains("docs/plan.md"),
"the record is kept: {record}"
);
assert!(
!out.stdout.contains("untracked"),
"the pulled file is still known: {}",
out.stdout
);
}
fn other_checkout(cli: &Cli) -> String {
cli.write("docs/plan.md", "# P");
let text = format!("server = \"{OTHER_ID}\"\nproject = \"proj\"\n");
cli.write(".semora", &text);
text
}
fn links_file(cli: &Cli) -> PathBuf {
cli.config.path().join("project-links.toml")
}
#[tokio::test(flavor = "multi_thread")]
async fn link_uses_your_server_here_and_leaves_the_file_alone() {
let server = Server::start().await;
let cli = Cli::new(&server);
let text = other_checkout(&cli);
let out = cli.run(&["project", "link"]).await;
assert_eq!(out.code, 0, "{}", out.stderr);
assert!(
format!("{}{}", out.stdout, out.stderr).contains("drift"),
"{} {}",
out.stdout,
out.stderr
);
let init = cli.run(&["project", "init", "."]).await;
assert_eq!(init.code, 0, "{}", init.stderr);
let out = cli.run(&["push", "docs/plan.md", "--json"]).await;
assert_eq!(out.code, 0, "{}", out.stderr);
assert_eq!(out.json()["project"], "proj");
assert!(
out.stderr
.contains("note: .semora is linked to your server on this machine only"),
"{}",
out.stderr
);
assert_eq!(std::fs::read_to_string(cli.path(".semora")).unwrap(), text);
}
#[tokio::test(flavor = "multi_thread")]
async fn link_with_project_uses_another_id() {
let server = Server::start().await;
let cli = Cli::new(&server);
other_checkout(&cli);
assert_eq!(
cli.run(&["project", "link", "--project", "mine"])
.await
.code,
0
);
let init = cli.run(&["project", "init", "."]).await;
assert_eq!(init.code, 0, "{}", init.stderr);
let out = cli.run(&["push", "docs/plan.md", "--json"]).await;
assert_eq!(out.code, 0, "{}", out.stderr);
assert_eq!(out.json()["project"], "mine");
}
#[tokio::test(flavor = "multi_thread")]
async fn unlink_brings_the_stop_back() {
let server = Server::start().await;
let cli = Cli::new(&server);
other_checkout(&cli);
assert_eq!(cli.run(&["project", "link"]).await.code, 0);
let out = cli.run(&["project", "unlink"]).await;
assert_eq!(out.code, 0, "{}", out.stderr);
assert!(out.stdout.contains("Dropped"), "{}", out.stdout);
let push = cli.run(&["push", "docs/plan.md"]).await;
assert_eq!(push.code, 1);
assert!(
push.stderr.contains("another Semora server"),
"{}",
push.stderr
);
let again = cli.run(&["project", "unlink"]).await;
assert_eq!(again.code, 0, "{}", again.stderr);
assert!(again.stdout.contains("has no link"), "{}", again.stdout);
}
#[tokio::test(flavor = "multi_thread")]
async fn link_rewrite_points_the_file_at_your_server() {
let server = Server::start().await;
let cli = Cli::new(&server);
other_checkout(&cli);
let out = cli.run(&["project", "link", "--rewrite"]).await;
assert_eq!(out.code, 0, "{}", out.stderr);
assert!(
format!("{}{}", out.stdout, out.stderr).contains("everyone else's checkouts stop working"),
"{} {}",
out.stdout,
out.stderr
);
assert_eq!(
std::fs::read_to_string(cli.path(".semora")).unwrap(),
format!(
"server = \"{}\"\nproject = \"proj\"\n",
id_of(&server).await
)
);
seed_project(&cli, "proj").await;
let push = cli.run(&["push", "docs/plan.md"]).await;
assert_eq!(push.code, 0, "{}", push.stderr);
assert!(!push.stderr.contains("note:"), "{}", push.stderr);
}
#[tokio::test(flavor = "multi_thread")]
async fn link_on_a_file_for_your_server_does_nothing() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("x.md", "# x");
assert_eq!(cli.run(&["project", "init", "."]).await.code, 0);
let out = cli.run(&["project", "link"]).await;
assert_eq!(out.code, 0, "{}", out.stderr);
assert!(
out.stdout.contains("already for your server"),
"{}",
out.stdout
);
assert!(!links_file(&cli).exists());
}
#[tokio::test(flavor = "multi_thread")]
async fn a_rename_on_your_server_updates_the_link_not_the_file() {
let server = Server::start().await;
let cli = Cli::new(&server);
let text = other_checkout(&cli);
assert_eq!(cli.run(&["project", "link"]).await.code, 0);
assert_eq!(cli.run(&["project", "init", "."]).await.code, 0);
assert_eq!(cli.run(&["push", "docs/plan.md"]).await.code, 0);
give_new_id(&server, "proj", "proj2").await;
cli.write("docs/plan.md", "# P v2");
let out = cli.run(&["push", "docs/plan.md", "--json"]).await;
assert_eq!(out.code, 0, "{}", out.stderr);
assert_eq!(out.json()["project"], "proj2");
assert!(
out.stderr.contains("Updated this machine's link"),
"{}",
out.stderr
);
assert_eq!(std::fs::read_to_string(cli.path(".semora")).unwrap(), text);
let links = std::fs::read_to_string(links_file(&cli)).unwrap();
assert!(links.contains("project = \"proj2\""), "{links}");
}
#[tokio::test(flavor = "multi_thread")]
async fn a_legacy_file_for_another_server_can_be_linked() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("docs/plan.md", "# P");
cli.write(
".semora",
"server = \"https://other.example.com\"\nproject = \"proj\"\n",
);
assert_eq!(cli.run(&["project", "link"]).await.code, 0);
assert_eq!(cli.run(&["project", "init", "."]).await.code, 0);
let out = cli.run(&["push", "docs/plan.md"]).await;
assert_eq!(out.code, 0, "{}", out.stderr);
}
#[tokio::test(flavor = "multi_thread")]
async fn when_the_file_names_your_server_a_link_with_another_id_is_dropped_with_a_warning() {
let server = Server::start().await;
let cli = Cli::new(&server);
other_checkout(&cli);
assert_eq!(
cli.run(&["project", "link", "--project", "mine"])
.await
.code,
0
);
seed_project(&cli, "proj").await;
cli.write(
".semora",
format!(
"server = \"{}\"\nproject = \"proj\"\n",
id_of(&server).await
),
);
let out = cli.run(&["push", "docs/plan.md", "--json"]).await;
assert_eq!(out.code, 0, "{}", out.stderr);
assert!(
out.stderr.contains("link to project 'mine' is dropped"),
"{}",
out.stderr
);
assert_eq!(out.json()["project"], "proj");
let links = std::fs::read_to_string(links_file(&cli)).unwrap_or_default();
assert!(!links.contains("mine"), "{links}");
}
#[tokio::test(flavor = "multi_thread")]
async fn a_link_from_another_repo_with_the_same_project_id_is_kept() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("a/docs/plan.md", "# A");
cli.write(
"a/.semora",
format!("server = \"{OTHER_ID}\"\nproject = \"docs\"\n"),
);
cli.write("b/docs/plan.md", "# B");
assert_eq!(
cli.run(&["project", "init", "b", "--name", "docs"])
.await
.code,
0
);
assert_eq!(
cli.run(&["project", "link", "a", "--project", "docs-a"])
.await
.code,
0
);
assert_eq!(cli.run(&["project", "init", "a"]).await.code, 0);
let b = cli.run(&["push", "b/docs/plan.md"]).await;
assert_eq!(b.code, 0, "{}", b.stderr);
assert!(!b.stderr.contains("dropped"), "{}", b.stderr);
let a = cli.run(&["push", "a/docs/plan.md", "--json"]).await;
assert_eq!(a.code, 0, "{}", a.stderr);
assert_eq!(a.json()["project"], "docs-a");
}
#[tokio::test(flavor = "multi_thread")]
async fn rewrite_on_a_link_with_another_project_id_keeps_that_id() {
let server = Server::start().await;
let cli = Cli::new(&server);
other_checkout(&cli);
assert_eq!(
cli.run(&["project", "link", "--project", "mine"])
.await
.code,
0
);
let out = cli.run(&["project", "link", "--rewrite"]).await;
assert_eq!(out.code, 0, "{}", out.stderr);
assert_eq!(
std::fs::read_to_string(cli.path(".semora")).unwrap(),
format!(
"server = \"{}\"\nproject = \"mine\"\n",
id_of(&server).await
)
);
}
#[tokio::test(flavor = "multi_thread")]
async fn project_init_writes_the_servers_current_id_not_a_stale_cached_one() {
let server = Server::start().await;
let cli = Cli::new(&server);
std::fs::write(
cli.config.path().join("server-ids.cache"),
format!("{}\t{OTHER_ID}\n", server.url),
)
.unwrap();
cli.write("x.md", "# x");
let out = cli.run(&["project", "init", "."]).await;
assert_eq!(out.code, 0, "{}", out.stderr);
let text = std::fs::read_to_string(cli.path(".semora")).unwrap();
assert!(
text.contains(&format!("server = \"{}\"", id_of(&server).await)),
"{text}"
);
}