mod common;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use common::*;
async fn init(cli: &Cli) {
let out = cli.run(&["project", "init", "--name", "demo"]).await;
assert_eq!(out.code, 0, "{}", out.stderr);
}
async fn put(server: &Server, slug: &str, body: &str) {
let client = reqwest::Client::new();
let resp = client
.put(format!("{}/api/v1/projects/{slug}/mappings", server.url))
.bearer_auth(&server.key)
.header("content-type", "application/json")
.body(body.to_string())
.send()
.await
.unwrap();
assert!(resp.status().is_success(), "{}", resp.status());
}
#[tokio::test(flavor = "multi_thread")]
async fn map_ls_says_when_there_are_none() {
let server = Server::start().await;
let cli = Cli::new(&server);
init(&cli).await;
let out = cli.run(&["project", "map", "ls"]).await;
assert_eq!(out.code, 0, "{}", out.stderr);
assert_eq!(
out.stdout.trim(),
"No mappings for demo. Every document shows at its real path."
);
}
#[tokio::test(flavor = "multi_thread")]
async fn map_ls_prints_the_rules_in_order() {
let server = Server::start().await;
let cli = Cli::new(&server);
init(&cli).await;
put(
&server,
"demo",
r#"{"mappings":[{"pattern":"docs/**","folder":"/"},{"pattern":"**","folder":"misc/"}]}"#,
)
.await;
let out = cli.run(&["project", "map", "ls"]).await;
assert_eq!(out.code, 0, "{}", out.stderr);
assert_eq!(
out.stdout,
"Mappings for demo (the first match wins; a path no rule matches stays where it is)\n 1 docs/** → /\n 2 ** → misc/\n"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn map_json_has_the_api_shape() {
let server = Server::start().await;
let cli = Cli::new(&server);
init(&cli).await;
put(
&server,
"demo",
r#"{"mappings":[{"pattern":"docs/**","folder":"/"}]}"#,
)
.await;
let v = cli.run(&["project", "map", "ls", "--json"]).await.json();
assert_eq!(
v,
serde_json::json!({"mappings":[{"pattern":"docs/**","folder":"/"}]})
);
}
#[tokio::test(flavor = "multi_thread")]
async fn map_finds_the_project_in_a_parent_folder() {
let server = Server::start().await;
let cli = Cli::new(&server);
init(&cli).await;
put(
&server,
"demo",
r#"{"mappings":[{"pattern":"**","folder":"misc/"}]}"#,
)
.await;
std::fs::create_dir_all(cli.path("sub/dir")).unwrap();
let mut cmd = cli.command(&["project", "map", "ls", "--json"]);
cmd.current_dir(cli.path("sub/dir"));
let out = tokio::task::spawn_blocking(move || cmd.output().unwrap())
.await
.unwrap();
assert_eq!(
out.status.code(),
Some(0),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(v["mappings"][0]["pattern"], "**");
}
#[tokio::test(flavor = "multi_thread")]
async fn map_project_flag_wins() {
let server = Server::start().await;
let cli = Cli::new(&server);
init(&cli).await; let cli2 = Cli::new(&server);
let out = cli2.run(&["project", "init", "--name", "other"]).await;
assert_eq!(out.code, 0, "{}", out.stderr);
put(
&server,
"other",
r#"{"mappings":[{"pattern":"x/**","folder":"y/"}]}"#,
)
.await;
let v = cli
.run(&["project", "map", "ls", "--project", "other", "--json"])
.await
.json();
assert_eq!(v["mappings"][0]["pattern"], "x/**");
}
#[tokio::test(flavor = "multi_thread")]
async fn map_without_a_project_says_what_to_do() {
let server = Server::start().await;
let cli = Cli::new(&server); let out = cli.run(&["project", "map", "ls"]).await;
assert_eq!(out.code, 1);
assert!(
out.stderr.contains("no project: pass --project ID"),
"{}",
out.stderr
);
}
#[tokio::test(flavor = "multi_thread")]
async fn map_help_names_the_project_flag_like_ls_does() {
let server = Server::start().await;
let cli = Cli::new(&server);
let map = cli.run(&["project", "map", "ls", "--help"]).await.stdout;
let ls = cli.run(&["ls", "--help"]).await.stdout;
assert!(ls.contains("--project <PROJECT>"), "{ls}");
assert!(map.contains("--project <PROJECT>"), "{map}");
}
#[tokio::test(flavor = "multi_thread")]
async fn map_for_an_unknown_project_exits_4() {
let server = Server::start().await;
let cli = Cli::new(&server);
let out = cli
.run(&["project", "map", "ls", "--project", "nope"])
.await;
assert_eq!(out.code, 4, "{}", out.stderr);
}
async fn counting_listener() -> (String, Arc<AtomicUsize>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
let hits = Arc::new(AtomicUsize::new(0));
let counter = hits.clone();
tokio::spawn(async move {
while let Ok((stream, _)) = listener.accept().await {
counter.fetch_add(1, Ordering::SeqCst);
drop(stream);
}
});
(url, hits)
}
#[tokio::test(flavor = "multi_thread")]
async fn map_stops_when_dot_semora_names_another_server() {
let server = Server::start().await;
let (in_use, hits) = counting_listener().await;
let mut cli = Cli::new(&server);
cli.url = in_use.clone();
cli.write(
".semora",
"server = \"http://127.0.0.1:9\"\nproject = \"demo\"\n",
);
for args in [
&["project", "map", "ls"][..],
&["project", "map", "add", "docs/**", "/"],
&["project", "map", "rm", "1"],
] {
let out = cli.run(args).await;
assert_eq!(out.code, 1, "{args:?}: {}", out.stderr);
assert!(
out.stderr.contains("http://127.0.0.1:9"),
"names the file's server: {}",
out.stderr
);
assert!(
out.stderr.contains(&in_use),
"and the one in use: {}",
out.stderr
);
}
assert_eq!(
hits.load(Ordering::SeqCst),
0,
"the server in use saw no request"
);
}
async fn patterns(cli: &Cli) -> Vec<String> {
let v = cli.run(&["project", "map", "ls", "--json"]).await.json();
v["mappings"]
.as_array()
.unwrap()
.iter()
.map(|m| m["pattern"].as_str().unwrap().to_string())
.collect()
}
#[tokio::test(flavor = "multi_thread")]
async fn map_add_appends_and_prints_the_list() {
let server = Server::start().await;
let cli = Cli::new(&server);
init(&cli).await;
let out = cli.run(&["project", "map", "add", "docs/**", "/"]).await;
assert_eq!(out.code, 0, "{}", out.stderr);
assert!(
out.stdout.starts_with("Added rule 1: docs/** → /\n"),
"{}",
out.stdout
);
let out = cli.run(&["project", "map", "add", "**", "misc/"]).await;
assert!(
out.stdout.starts_with("Added rule 2: ** → misc/\n"),
"{}",
out.stdout
);
assert!(
out.stdout
.contains(" 1 docs/** → /\n 2 ** → misc/\n"),
"{}",
out.stdout
);
}
#[tokio::test(flavor = "multi_thread")]
async fn map_add_at_inserts_and_renumbers() {
let server = Server::start().await;
let cli = Cli::new(&server);
init(&cli).await;
cli.run(&["project", "map", "add", "docs/**", "/"]).await;
cli.run(&["project", "map", "add", "**", "misc/"]).await;
let out = cli
.run(&["project", "map", "add", "*.md", "notes/", "--at", "1"])
.await;
assert_eq!(out.code, 0, "{}", out.stderr);
assert!(
out.stdout.starts_with("Added rule 1: *.md → notes/\n"),
"{}",
out.stdout
);
assert_eq!(patterns(&cli).await, ["*.md", "docs/**", "**"]);
}
#[tokio::test(flavor = "multi_thread")]
async fn map_add_at_out_of_range_changes_nothing() {
let server = Server::start().await;
let cli = Cli::new(&server);
init(&cli).await;
cli.run(&["project", "map", "add", "docs/**", "/"]).await;
cli.run(&["project", "map", "add", "**", "misc/"]).await;
for at in ["0", "4"] {
let out = cli
.run(&["project", "map", "add", "x/**", "y/", "--at", at])
.await;
assert_eq!(out.code, 1, "--at {at}");
assert!(
out.stderr.contains("there are 2 rules; --at can be 1 to 3"),
"{}",
out.stderr
);
}
assert_eq!(patterns(&cli).await, ["docs/**", "**"]);
}
#[tokio::test(flavor = "multi_thread")]
async fn map_add_prints_the_stored_form() {
let server = Server::start().await;
let cli = Cli::new(&server);
init(&cli).await;
let out = cli.run(&["project", "map", "add", "/docs/", "."]).await;
assert_eq!(out.code, 0, "{}", out.stderr);
assert!(
out.stdout.starts_with("Added rule 1: docs/** → /\n"),
"{}",
out.stdout
);
}
#[tokio::test(flavor = "multi_thread")]
async fn map_rm_says_which_rule_it_removed() {
let server = Server::start().await;
let cli = Cli::new(&server);
init(&cli).await;
cli.run(&["project", "map", "add", "docs/**", "/"]).await;
cli.run(&["project", "map", "add", "**", "misc/"]).await;
let out = cli.run(&["project", "map", "rm", "1"]).await;
assert_eq!(out.code, 0, "{}", out.stderr);
assert!(
out.stdout.starts_with("Removed rule 1: docs/** → /\n"),
"{}",
out.stdout
);
assert!(
out.stdout.contains(" 1 ** → misc/\n"),
"the rest is renumbered: {}",
out.stdout
);
}
#[tokio::test(flavor = "multi_thread")]
async fn map_rm_with_no_such_rule_changes_nothing() {
let server = Server::start().await;
let cli = Cli::new(&server);
init(&cli).await;
cli.run(&["project", "map", "add", "docs/**", "/"]).await;
cli.run(&["project", "map", "add", "**", "misc/"]).await;
for n in ["0", "5"] {
let out = cli.run(&["project", "map", "rm", n]).await;
assert_eq!(out.code, 1, "rm {n}");
assert!(
out.stderr
.contains(&format!("there is no rule {n}; there are 2")),
"{}",
out.stderr
);
}
assert_eq!(patterns(&cli).await, ["docs/**", "**"]);
}
#[tokio::test(flavor = "multi_thread")]
async fn map_shows_the_servers_message_for_a_bad_rule() {
let server = Server::start().await;
let cli = Cli::new(&server);
init(&cli).await;
let out = cli.run(&["project", "map", "add", "a**", "/"]).await;
assert_eq!(out.code, 1);
assert!(
out.stderr.contains("rule 1: bad pattern `a**`"),
"{}",
out.stderr
);
assert!(patterns(&cli).await.is_empty(), "nothing was stored");
}
#[tokio::test(flavor = "multi_thread")]
async fn a_drive_path_argument_gets_a_hint() {
let server = Server::start().await;
let cli = Cli::new(&server);
init(&cli).await;
for args in [
["project", "map", "add", "C:/Program Files/Git/docs/**", "."],
["project", "map", "add", "docs/**", "C:/Program Files/Git/"],
] {
let out = cli.run(&args).await;
assert_eq!(out.code, 1, "{args:?}");
assert!(
out.stderr.contains("looks like a drive path"),
"{}",
out.stderr
);
assert!(out.stderr.contains("Git Bash"), "{}", out.stderr);
}
assert!(patterns(&cli).await.is_empty(), "nothing was sent");
}
#[tokio::test(flavor = "multi_thread")]
async fn map_add_with_extra_arguments_is_a_usage_error() {
let server = Server::start().await;
let cli = Cli::new(&server);
init(&cli).await;
let out = cli
.run(&["project", "map", "add", "docs/a.md", "docs/b.md", "/"])
.await;
assert_eq!(out.code, 2, "clap's usage error: {}", out.stderr);
assert!(patterns(&cli).await.is_empty());
}