semora-cli 0.3.0

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

use std::path::Path;

use common::*;

#[tokio::test(flavor = "multi_thread")]
async fn push_file_then_push_again_replaces_it() {
    let server = Server::start().await;
    let cli = Cli::new(&server);
    cli.write("notes.md", "# Notes\nfirst");
    let out = cli.run(&["push", "notes.md", "--json"]).await;
    assert_eq!(out.code, 0, "{}", out.stderr);
    let v = out.json();
    let id = v["id"].as_str().unwrap().to_string();
    assert_eq!(v["rev"], 1);
    assert_eq!(v["title"], "Notes");

    let cache = cli.cache();
    assert_eq!(cache.len(), 1, "{cache:?}");
    assert!(
        Path::new(&cache[0][0]).is_absolute() && cache[0][0].ends_with("notes.md"),
        "{cache:?}"
    );
    assert_eq!(cache[0][1..], [server.url.clone(), id.clone()]);

    cli.write("notes.md", "# Notes\nsecond");
    let v = cli.run(&["push", "notes.md", "--json"]).await.json();
    assert_eq!(v["id"], id.as_str(), "same document");
    assert_eq!(v["rev"], 2);

    let v = cli
        .run(&["push", "notes.md", "--new", "--json"])
        .await
        .json();
    assert_ne!(v["id"], id.as_str(), "--new forces a new document");
    let cache = cli.cache();
    assert_eq!(
        cache.len(),
        1,
        "the new document replaces the line: {cache:?}"
    );
    assert_eq!(cache[0][2], v["id"].as_str().unwrap());
}

#[tokio::test(flavor = "multi_thread")]
async fn push_text_output_shows_urls() {
    let server = Server::start().await;
    let cli = Cli::new(&server);
    cli.write("a.md", "# Alpha");
    let out = cli
        .run(&["push", "a.md", "-m", "first", "--tag", "x,y"])
        .await;
    assert_eq!(out.code, 0, "{}", out.stderr);
    assert!(
        out.stdout.contains(&format!("{}/d/", server.url)),
        "{}",
        out.stdout
    );
    assert!(out.stdout.contains("ws://127.0.0.1"), "{}", out.stdout);
    assert!(out.stdout.contains("Alpha"));
}

#[tokio::test(flavor = "multi_thread")]
async fn push_directory_as_bundle_skipping_hidden_and_unsupported() {
    let server = Server::start().await;
    let cli = Cli::new(&server);
    cli.write("site/index.md", "# Site\n[guide](guide/setup.md)");
    cli.write("site/guide/setup.md", "# Setup");
    cli.write("site/img/logo.png", b"\x89PNG\r\n\x1a\n");
    cli.write("site/.git/config", "[core]");
    cli.write("site/node_modules/x/index.js", "x");
    cli.write("site/notes.txt", "not served");
    let out = cli.run(&["push", "site", "--json"]).await;
    assert_eq!(out.code, 0, "{}", out.stderr);
    let v = out.json();
    assert_eq!(v["kind"], "bundle");
    assert_eq!(v["files"], 3, "{v}");
    assert!(
        out.stderr.contains("notes.txt"),
        "unsupported files are named in a warning: {}",
        out.stderr
    );
    assert!(cli.cache()[0][0].ends_with("site"), "{:?}", cli.cache());

    // The second push replaces.
    let v2 = cli.run(&["push", "site", "--json"]).await.json();
    assert_eq!(v2["id"], v["id"]);
    assert_eq!(v2["files"], 3);
}

#[tokio::test(flavor = "multi_thread")]
async fn single_file_with_local_links_warns() {
    let server = Server::start().await;
    let cli = Cli::new(&server);
    cli.write("index.md", "# Home\nSee [the guide](guide.md).");
    cli.write("guide.md", "# Guide");
    let out = cli.run(&["push", "index.md"]).await;
    assert_eq!(out.code, 0);
    assert!(
        out.stderr.to_lowercase().contains("bundle"),
        "{}",
        out.stderr
    );
}

#[tokio::test(flavor = "multi_thread")]
async fn get_show_ls_and_whoami() {
    let server = Server::start().await;
    let cli = Cli::new(&server);
    cli.write("doc.md", "# Doc Title\nbody text");
    let id = cli
        .run(&["push", "doc.md", "--json", "--tag", "alpha"])
        .await
        .json()["id"]
        .as_str()
        .unwrap()
        .to_string();

    let out = cli.run(&["get", &id]).await;
    assert_eq!(out.code, 0);
    assert_eq!(out.stdout, "# Doc Title\nbody text");

    let out = cli.run(&["get", &id, "doc.md", "-o", "copy.md"]).await;
    assert_eq!(out.code, 0, "{}", out.stderr);
    assert_eq!(
        std::fs::read_to_string(cli.path("copy.md")).unwrap(),
        "# Doc Title\nbody text"
    );

    let v = cli.run(&["show", &id, "--json"]).await.json();
    assert_eq!(v["title"], "Doc Title");
    assert_eq!(v["tags"], serde_json::json!(["alpha"]));
    let out = cli.run(&["show", &format!("{}/d/{id}/", server.url)]).await;
    assert_eq!(out.code, 0, "ids may be given as URLs: {}", out.stderr);
    assert!(out.stdout.contains("Doc Title"));

    let out = cli.run(&["ls"]).await;
    assert!(
        out.stdout.contains(&id) && out.stdout.contains("Doc Title"),
        "{}",
        out.stdout
    );
    let v = cli.run(&["ls", "--json"]).await.json();
    assert_eq!(v["docs"][0]["id"], id.as_str());

    let v = cli.run(&["whoami", "--json"]).await.json();
    assert_eq!(v["user"]["email"], "admin@example.com");
    assert_eq!(v["key"]["name"], "cli-test");
}

#[tokio::test(flavor = "multi_thread")]
async fn tag_and_rm() {
    let server = Server::start().await;
    let cli = Cli::new(&server);
    cli.write("t.md", "# T");
    let id = cli
        .run(&["push", "t.md", "--json", "--tag", "old"])
        .await
        .json()["id"]
        .as_str()
        .unwrap()
        .to_string();
    let out = cli.run(&["tag", &id, "+new", "-old", "plain"]).await;
    assert_eq!(out.code, 0, "{}", out.stderr);
    let v = cli.run(&["show", &id, "--json"]).await.json();
    assert_eq!(v["tags"], serde_json::json!(["new", "plain"]));
    assert_eq!(cli.run(&["rm", &id]).await.code, 0);
    assert_eq!(cli.run(&["show", &id]).await.code, 4);
}

#[tokio::test(flavor = "multi_thread")]
async fn exit_codes_for_auth_and_missing_documents() {
    let server = Server::start().await;
    let mut cli = Cli::new(&server);
    let out = cli.run(&["show", "AAAAAAAAAA"]).await;
    assert_eq!(out.code, 4, "{}", out.stderr);
    cli.key = Some("sm_AAAAAAAA_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".into());
    let out = cli.run(&["whoami"]).await;
    assert_eq!(out.code, 3, "{}", out.stderr);
    cli.key = None;
    let out = cli.run(&["whoami"]).await;
    assert_eq!(
        out.code, 3,
        "no key at all is an auth error: {}",
        out.stderr
    );
    assert!(out.stderr.contains("semora login"), "{}", out.stderr);
}

#[tokio::test(flavor = "multi_thread")]
async fn dotenv_supplies_the_key_but_real_env_wins() {
    let server = Server::start().await;
    let mut cli = Cli::new(&server);
    cli.write(".env", format!("SEMORA_API_KEY={}\n", server.key));
    cli.key = None;
    let out = cli.run(&["whoami"]).await;
    assert_eq!(out.code, 0, ".env key used: {}", out.stderr);

    cli.key = Some("sm_BBBBBBBB_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB".into());
    assert_eq!(cli.run(&["whoami"]).await.code, 3, "process env beats .env");

    let out = cli.run(&["whoami", "--api-key", &server.key]).await;
    assert_eq!(out.code, 0, "--api-key beats everything: {}", out.stderr);
}

#[tokio::test(flavor = "multi_thread")]
async fn dotenv_in_a_parent_directory_is_found() {
    let server = Server::start().await;
    let mut cli = Cli::new(&server);
    cli.write(
        ".env",
        format!("SEMORA_API_KEY={}\nSEMORA_DEFAULT_TAGS=proj\n", server.key),
    );
    cli.write("sub/dir/x.md", "# X");
    cli.key = None;
    let mut cmd = cli.command(&["push", "x.md", "--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 id = serde_json::from_slice::<serde_json::Value>(&out.stdout).unwrap()["id"]
        .as_str()
        .unwrap()
        .to_string();
    cli.key = Some(server.key.clone());
    let v = cli.run(&["show", &id, "--json"]).await.json();
    assert_eq!(
        v["tags"],
        serde_json::json!(["proj"]),
        "SEMORA_DEFAULT_TAGS from .env"
    );
}

#[tokio::test(flavor = "multi_thread")]
async fn meta_pairs_become_json() {
    let server = Server::start().await;
    let cli = Cli::new(&server);
    cli.write("m.md", "# M");
    let id = cli
        .run(&[
            "push",
            "m.md",
            "--json",
            "--meta",
            "project=seed",
            "--meta",
            "priority=2",
        ])
        .await
        .json()["id"]
        .as_str()
        .unwrap()
        .to_string();
    let v = cli.run(&["show", &id, "--json"]).await.json();
    assert_eq!(
        v["meta"],
        serde_json::json!({"project": "seed", "priority": 2})
    );
}

#[tokio::test(flavor = "multi_thread")]
async fn remembered_but_deleted_document_gets_recreated() {
    let server = Server::start().await;
    let cli = Cli::new(&server);
    cli.write("r.md", "# R");
    let id = cli.run(&["push", "r.md", "--json"]).await.json()["id"]
        .as_str()
        .unwrap()
        .to_string();
    cli.run(&["rm", &id]).await;
    let out = cli.run(&["push", "r.md", "--json"]).await;
    assert_eq!(out.code, 0, "{}", out.stderr);
    assert_ne!(out.json()["id"], id.as_str());
    assert!(out.stderr.contains("new document"), "{}", out.stderr);
}

#[tokio::test(flavor = "multi_thread")]
async fn push_to_explicit_id_that_does_not_exist_fails_with_4() {
    let server = Server::start().await;
    let cli = Cli::new(&server);
    cli.write("x.md", "# X");
    assert_eq!(
        cli.run(&["push", "x.md", "--id", "ZZZZZZZZZZ"]).await.code,
        4
    );
}

#[tokio::test(flavor = "multi_thread")]
async fn keys_create_list_and_revoke() {
    let server = Server::start().await;
    let mut cli = Cli::new(&server);
    let out = cli
        .run(&[
            "keys",
            "create",
            "ci-bot",
            "--scopes",
            "docs:read",
            "--expires-in-days",
            "7",
            "--json",
        ])
        .await;
    assert_eq!(out.code, 0, "{}", out.stderr);
    let created = out.json();
    let secret = created["secret"].as_str().unwrap().to_string();
    let id = created["key"]["id"].as_i64().unwrap();
    assert_eq!(created["key"]["scopes"], serde_json::json!(["docs:read"]));
    let listed = cli.run(&["keys", "ls", "--json"]).await.json();
    assert!(
        listed
            .as_array()
            .unwrap()
            .iter()
            .any(|k| k["name"] == "ci-bot")
    );
    let text = cli.run(&["keys", "ls"]).await.stdout;
    assert!(
        text.contains("ci-bot") && text.contains("expires"),
        "{text}"
    );

    let as_bot = Cli {
        key: Some(secret.clone()),
        ..Cli::new(&server)
    };
    cli.write("x.md", "# X");
    assert_eq!(as_bot.run(&["whoami"]).await.code, 0);
    assert_eq!(cli.run(&["keys", "revoke", &id.to_string()]).await.code, 0);
    cli.key = Some(secret);
    assert_eq!(cli.run(&["whoami"]).await.code, 3);
}

#[tokio::test(flavor = "multi_thread")]
async fn skill_prints_the_same_guide_as_the_server() {
    let server = Server::start().await;
    let cli = Cli::new(&server);
    let out = cli.run(&["skill"]).await;
    assert_eq!(out.code, 0);
    let served = reqwest::get(format!("{}/docs.md", server.url))
        .await
        .unwrap()
        .text()
        .await
        .unwrap();
    assert_eq!(out.stdout, served);
    assert!(out.stdout.contains(&server.url));
}

#[tokio::test(flavor = "multi_thread")]
async fn missing_server_url_is_a_clear_error() {
    let server = Server::start().await;
    let mut cli = Cli::new(&server);
    cli.url = String::new();
    let mut cmd = cli.command(&["whoami"]);
    cmd.env_remove("SEMORA_URL");
    let out = tokio::task::spawn_blocking(move || cmd.output().unwrap())
        .await
        .unwrap();
    assert_eq!(out.status.code(), Some(1));
    assert!(String::from_utf8_lossy(&out.stderr).contains("SEMORA_URL"));
}