aven 0.1.10

Local-first task manager CLI and sync server
Documentation
mod common;

use common::{TestEnv, TestServer, contains_all, contains_none, extract_ref, fail, ok};

fn sync(env: &TestEnv, db: &std::path::Path, server: &TestServer) {
    ok(env.aven(db, ["sync", "--server", &server.url]));
}

fn synced_task(
    env: &TestEnv,
    server: &TestServer,
    title: &str,
) -> (std::path::PathBuf, std::path::PathBuf, String) {
    let a = env.db("client-a.sqlite");
    let b = env.db("client-b.sqlite");
    let task_ref = extract_ref(&ok(env.aven(&a, ["add", title, "--project", "app"])));
    sync(env, &a, server);
    sync(env, &b, server);
    (a, b, task_ref)
}

fn title_conflict(
    env: &TestEnv,
    server: &TestServer,
) -> (std::path::PathBuf, std::path::PathBuf, String) {
    let (a, b, task_ref) = synced_task(env, server, "conflict base");
    ok(env.aven(&a, ["edit", &task_ref, "--title", "title from a"]));
    ok(env.aven(&b, ["edit", &task_ref, "--title", "title from b"]));
    sync(env, &a, server);
    sync(env, &b, server);
    sync(env, &a, server);
    (a, b, task_ref)
}

fn deleted_conflict(
    env: &TestEnv,
    server: &TestServer,
) -> (std::path::PathBuf, std::path::PathBuf, String) {
    let (a, b, task_ref) = synced_task(env, server, "deleted conflict");
    ok(env.aven(&b, ["delete", &task_ref]));
    ok(env.aven(&a, ["delete", &task_ref]));
    ok(env.aven(&a, ["restore", &task_ref]));
    sync(env, &b, server);
    sync(env, &a, server);
    (a, b, task_ref)
}

#[test]
fn same_field_edit_creates_conflict() {
    let env = TestEnv::new();
    let server = TestServer::start(&env);
    let (a, _, task_ref) = title_conflict(&env, &server);

    let conflicts = ok(env.aven(&a, ["conflict", "list"]));
    contains_all(&conflicts, &[&task_ref, "conflict field=title"]);
    let listed = ok(env.aven(&a, ["list", "--all"]));
    contains_all(&listed, &[&task_ref, "conflicts=yes"]);
}

#[test]
fn conflicted_field_is_protected_but_other_fields_work() {
    let env = TestEnv::new();
    let server = TestServer::start(&env);
    let (a, _, task_ref) = title_conflict(&env, &server);

    let error = fail(env.aven(&a, ["edit", &task_ref, "--title", "should fail"]));
    contains_all(&error, &["error conflicted-field", "field=title"]);

    ok(env.aven(&a, ["edit", &task_ref, "--priority", "urgent"]));
    let shown = ok(env.aven(&a, ["show", &task_ref]));
    contains_all(&shown, &["priority=urgent", "conflicts=yes"]);
}

#[test]
fn resolve_conflict_by_variant_syncs() {
    let env = TestEnv::new();
    let server = TestServer::start(&env);
    let (a, b, task_ref) = title_conflict(&env, &server);

    let shown = ok(env.aven(&a, ["conflict", "show", &task_ref, "--field", "title"]));
    contains_all(&shown, &["conflict", "value<<EOF"]);
    let token = shown
        .lines()
        .find_map(|line| line.strip_prefix("variant "))
        .and_then(|line| line.split_whitespace().next())
        .expect("variant token");

    ok(env.aven(
        &a,
        ["conflict", "resolve", &task_ref, "title", "--use", token],
    ));
    sync(&env, &a, &server);
    sync(&env, &b, &server);

    let conflicts_a = ok(env.aven(&a, ["conflict", "list"]));
    let conflicts_b = ok(env.aven(&b, ["conflict", "list"]));
    contains_none(&conflicts_a, &[&task_ref]);
    contains_none(&conflicts_b, &[&task_ref]);
}

#[test]
fn conflict_export_and_diff_write_variant_files() {
    let env = TestEnv::new();
    let server = TestServer::start(&env);
    let (a, b, task_ref) = synced_task(&env, &server, "description conflict export");
    ok(env.aven(
        &a,
        ["edit", &task_ref, "--description", "description from a\n"],
    ));
    ok(env.aven(
        &b,
        ["edit", &task_ref, "--description", "description from b\n"],
    ));
    sync(&env, &a, &server);
    sync(&env, &b, &server);
    sync(&env, &a, &server);

    let diff = ok(env.aven(&a, ["conflict", "diff", &task_ref, "description"]));
    contains_all(&diff, &["---", "+++", "description from"]);

    let dir = env.path("conflicts");
    let exported = ok(env.aven(
        &a,
        [
            "conflict",
            "export",
            &task_ref,
            "description",
            "--dir",
            dir.to_str().unwrap(),
        ],
    ));
    contains_all(&exported, &["exported variant=", "path="]);

    let mut names = Vec::new();
    let mut bodies = Vec::new();
    for entry in std::fs::read_dir(&dir).unwrap() {
        let path = entry.unwrap().path();
        names.push(path.file_name().unwrap().to_string_lossy().to_string());
        bodies.push(std::fs::read_to_string(path).unwrap());
    }
    names.sort();
    bodies.sort();
    assert!(names.iter().all(|name| name.starts_with("description-v")));
    assert!(names.iter().all(|name| name.ends_with(".md")));
    assert_eq!(bodies, vec!["description from a\n", "description from b\n"]);
}

#[test]
fn resolve_conflict_by_stdin_syncs() {
    let env = TestEnv::new();
    let server = TestServer::start(&env);
    let (a, b, task_ref) = synced_task(&env, &server, "description conflict");

    ok(env.aven(
        &a,
        ["edit", &task_ref, "--description", "description from a"],
    ));
    ok(env.aven(
        &b,
        ["edit", &task_ref, "--description", "description from b"],
    ));
    sync(&env, &a, &server);
    sync(&env, &b, &server);

    ok(env.aven_stdin(
        &b,
        [
            "conflict",
            "resolve",
            &task_ref,
            "description",
            "--value-stdin",
        ],
        "resolved description\n",
    ));
    sync(&env, &b, &server);
    sync(&env, &a, &server);

    let resolved = ok(env.aven(&a, ["show", &task_ref, "--full"]));
    contains_all(&resolved, &["resolved description"]);
    contains_none(&resolved, &["conflict ", "field=description"]);
}

#[test]
fn invalid_deleted_conflict_resolution_preserves_task_and_change_log() {
    let env = TestEnv::new();
    let server = TestServer::start(&env);
    let (a, _, task_ref) = deleted_conflict(&env, &server);
    let changes_before = common::scalar_i64(&a, "SELECT count(*) FROM changes");

    let error = fail(env.aven(
        &a,
        [
            "conflict", "resolve", &task_ref, "deleted", "--value", "true",
        ],
    ));

    contains_all(&error, &["error invalid-deleted"]);
    assert_eq!(
        common::scalar_i64(&a, "SELECT count(*) FROM tasks WHERE deleted = 1"),
        0
    );
    assert_eq!(
        common::scalar_i64(&a, "SELECT count(*) FROM changes"),
        changes_before
    );
    let conflicts = ok(env.aven(&a, ["conflict", "list"]));
    contains_all(&conflicts, &[&task_ref, "conflict field=deleted"]);
}

#[test]
fn remote_epic_demotion_conflict_displays_on_off() {
    let env = TestEnv::new();
    let server = TestServer::start(&env);
    let a = env.db("epic-conflict-a.sqlite");
    let b = env.db("epic-conflict-b.sqlite");

    let epic = extract_ref(&ok(
        env.aven(&a, ["add", "sync epic", "--project", "app", "--epic"])
    ));
    sync(&env, &a, &server);
    sync(&env, &b, &server);
    let child = extract_ref(&ok(env.aven(&b, ["add", "sync child", "--project", "app"])));
    ok(env.aven(&b, ["epic", "add", &child, &epic]));
    ok(env.aven(&a, ["edit", &epic, "--epic", "off"]));
    sync(&env, &b, &server);
    sync(&env, &a, &server);
    sync(&env, &b, &server);

    let conflict = ok(env.aven(&b, ["conflict", "show", &epic, "--field", "is_epic"]));
    contains_all(&conflict, &["field=is_epic", "on", "off"]);
    let full = ok(env.aven(&b, ["show", &epic, "--full"]));
    contains_all(&full, &["field=is_epic", "on", "off"]);
    let full_json = ok(env.aven(&b, ["show", &epic, "--full", "--json"]));
    contains_all(&full_json, &["\"field\": \"is_epic\"", "\"on\"", "\"off\""]);
    let resolve_error = fail(env.aven(
        &b,
        ["conflict", "resolve", &epic, "is_epic", "--value", "0"],
    ));
    contains_all(&resolve_error, &["error epic-has-children"]);
}

#[test]
fn conflict_list_json_supports_limit() {
    let env = TestEnv::new();
    let server = TestServer::start(&env);
    let (a, _, _task_ref) = title_conflict(&env, &server);

    let listed = ok(env.aven(&a, ["conflict", "list", "--json", "--limit", "1"]));
    let listed: serde_json::Value = serde_json::from_str(&listed).unwrap();
    assert_eq!(listed.as_array().unwrap().len(), 1);
    assert_eq!(listed[0]["field"], "title");
    assert!(listed[0]["variants"].as_array().unwrap().len() >= 2);
}

#[test]
fn conflict_show_json_returns_detail() {
    let env = TestEnv::new();
    let server = TestServer::start(&env);
    let (a, _, task_ref) = title_conflict(&env, &server);

    let shown = ok(env.aven(
        &a,
        ["conflict", "show", &task_ref, "--field", "title", "--json"],
    ));
    let shown: serde_json::Value = serde_json::from_str(&shown).unwrap();
    assert_eq!(shown.as_array().unwrap().len(), 1);
    assert_eq!(shown[0]["field"], "title");
    assert!(shown[0]["variants"][0]["token"].is_string());
    assert!(shown[0]["variants"][0]["value"].is_string());
}