semora-cli 0.4.0

Semora CLI: publish Markdown and HTML, and hear back from the page
Documentation
mod common;

use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};

use common::*;

/// A folder with a `.semora` for project `demo`, made by Core's `project init`.
async fn init(cli: &Cli) {
    let out = cli.run(&["project", "init", "--name", "demo"]).await;
    assert_eq!(out.code, 0, "{}", out.stderr);
}

/// Sets a project's rules straight through the API, as the app would.
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; // .semora says demo
    // A second project the folder doesn't name.
    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); // no .semora anywhere above the temp folder
    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);
}

/// Stands in for a server that must not be called: counts connections and closes each one.
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"
    );
}

/// The patterns stored for `demo`, in order.
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() {
    // What a shell does to an unquoted `docs/**` when docs/ has files.
    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());
}