aven 0.1.4

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

use std::fs;

use common::{TestEnv, contains_all, extract_ref, ok};

#[test]
fn natural_add_uses_configured_task_intake_command() {
    let env = TestEnv::new();
    let db = env.db("natural.sqlite");
    let command = env.path("task-intake.sh");
    let prompt = env.path("prompt.txt");
    fs::write(
        &command,
        format!(
            "#!/bin/sh\ncat >'{}'\nprintf '%s\\n' '{{\"title\":\"fix slack dispatch\",\"description\":\"details from model\",\"project\":\"app\",\"priority\":\"high\",\"labels\":[]}}'\n",
            prompt.display()
        ),
    )
    .unwrap();
    set_executable(&command);
    env.write_config(&format!(
        r#"
local:
  db_path: "{}"

agent:
  task_intake:
    command: "{}"
    args: []
    timeout_seconds: 5
    system_prompt: "custom task shaping"
"#,
        db.display(),
        command.display()
    ));

    ok(env.aven_config(["project", "create", "app"]));
    let task_ref = extract_ref(&ok(env.aven_config([
        "add",
        "in slack-agent, we need to fix dispatch",
        "--natural",
    ])));

    let shown = ok(env.aven_config(["show", &task_ref, "--full"]));
    contains_all(
        &shown,
        &[
            "title=\"fix slack dispatch\"",
            "project=app",
            "priority=high",
            "description<<EOF",
            "details from model",
        ],
    );
    let prompt = fs::read_to_string(prompt).unwrap();
    assert_eq!(prompt, "custom task shaping");
}

#[test]
fn natural_add_expands_custom_task_intake_prompt_placeholders() {
    let env = TestEnv::new();
    let db = env.db("natural-template.sqlite");
    let command = env.path("task-intake-template.sh");
    let prompt = env.path("prompt-template.txt");
    fs::write(
        &command,
        format!(
            "#!/bin/sh\ncat >'{}'\nprintf '%s\n' '{{\"title\":\"fix slack dispatch\",\"description\":\"\",\"project\":null,\"priority\":\"none\",\"labels\":[]}}'\n",
            prompt.display()
        ),
    )
    .unwrap();
    set_executable(&command);
    env.write_config(&format!(
        r#"
local:
  db_path: "{}"

agent:
  task_intake:
    command: "{}"
    args: []
    timeout_seconds: 5
    system_prompt: |
      Input={{input}}
      Priorities={{priorities}}
      Inferred={{inferred_project}}
      Projects:
      {{projects}}
      Labels:
      {{labels}}
"#,
        db.display(),
        command.display()
    ));

    ok(env.aven_config(["project", "create", "App"]));
    ok(env.aven_config(["label", "create", "Bug"]));
    let task_ref = extract_ref(&ok(env.aven_config([
        "add",
        "in slack-agent, we need to fix dispatch",
        "--natural",
    ])));

    let shown = ok(env.aven_config(["show", &task_ref, "--full"]));
    contains_all(&shown, &["title=\"fix slack dispatch\"", "priority=none"]);
    let prompt = fs::read_to_string(prompt).unwrap();
    contains_all(
        &prompt,
        &[
            "Input=in slack-agent, we need to fix dispatch",
            "Priorities=none, low, medium, high, urgent",
            "Inferred=aven",
            "Projects:\n- app (App)",
            "Labels:\n- bug",
        ],
    );
}

#[test]
fn internal_natural_add_uses_explicit_workspace_id_and_project_context() {
    let env = TestEnv::new();
    let db = env.db("natural-internal.sqlite");
    let command = env.path("task-intake.sh");
    let prompt = env.path("prompt.txt");
    fs::write(
        &command,
        format!(
            "#!/bin/sh\ncat >'{}'\nprintf '%s\\n' '{{\"title\":\"fix slack sync\",\"description\":\"from model\",\"project\":null,\"priority\":\"none\",\"labels\":[]}}'\n",
            prompt.display()
        ),
    )
    .unwrap();
    set_executable(&command);
    env.write_config(&format!(
        r#"
local:
  db_path: "{}"

agent:
  task_intake:
    command: "{}"
    args: []
    timeout_seconds: 5
    system_prompt: "Project={{inferred_project}}"
"#,
        db.display(),
        command.display()
    ));

    ok(env.aven_config(["workspace", "create", "client"]));
    let client_workspace_id = workspace_id(&db, "client");
    ok(env.aven_config(["--workspace", "client", "project", "create", "app"]));

    let out = ok(env.aven_config([
        "internal",
        "natural-add",
        "--workspace-id",
        &client_workspace_id,
        "--project",
        "app",
        "--input",
        "in slack, we need to fix sync",
    ]));
    let task_ref = extract_ref(&out);

    let created = ok(env.aven_config(["--workspace", "client", "show", &task_ref, "--full"]));
    contains_all(
        &created,
        &[
            "project=app",
            "title=\"fix slack sync\"",
            "description<<EOF",
        ],
    );
    let default_list = ok(env.aven_config(["list"]));
    assert!(!default_list.contains("fix slack sync"));

    let prompt = fs::read_to_string(prompt).unwrap();
    assert!(prompt.contains("Project=app"));
    assert_eq!(pending_undo_count(&db, &client_workspace_id), 0);
}

#[test]
fn internal_natural_add_can_record_tui_undo() {
    let env = TestEnv::new();
    let db = env.db("natural-internal-undo.sqlite");
    let command = env.path("task-intake-undo.sh");
    fs::write(
        &command,
        "#!/bin/sh\ncat >/dev/null\nprintf '%s\\n' '{\"title\":\"fix undoable sync\",\"description\":\"from model\",\"project\":null,\"priority\":\"none\",\"labels\":[]}'\n",
    )
    .unwrap();
    set_executable(&command);
    env.write_config(&format!(
        r#"
local:
  db_path: "{}"

agent:
  task_intake:
    command: "{}"
    args: []
    timeout_seconds: 5
"#,
        db.display(),
        command.display()
    ));

    ok(env.aven_config(["workspace", "create", "client"]));
    let client_workspace_id = workspace_id(&db, "client");
    let out = ok(env.aven_config([
        "internal",
        "natural-add",
        "--workspace-id",
        &client_workspace_id,
        "--input",
        "make this undoable",
        "--tui-undo",
    ]));
    let task_ref = extract_ref(&out);

    let created = ok(env.aven_config(["--workspace", "client", "show", &task_ref, "--full"]));
    contains_all(&created, &["title=\"fix undoable sync\""]);
    assert_eq!(pending_undo_count(&db, &client_workspace_id), 1);
}

fn workspace_id(db: &std::path::Path, key: &str) -> String {
    sqlite_scalar(
        db,
        &format!("SELECT id FROM workspaces WHERE key = '{key}'"),
    )
}

fn pending_undo_count(db: &std::path::Path, workspace_id: &str) -> i64 {
    sqlite_scalar(
        db,
        &format!(
            "SELECT count(*) FROM tui_undo_entries WHERE workspace_id = '{workspace_id}' AND undone_at IS NULL"
        ),
    )
    .parse()
    .unwrap()
}

fn sqlite_scalar(db: &std::path::Path, query: &str) -> String {
    let output = std::process::Command::new("sqlite3")
        .arg(db)
        .arg(query)
        .output()
        .expect("read sqlite scalar");
    assert!(output.status.success(), "sqlite failed");
    String::from_utf8(output.stdout).unwrap().trim().to_string()
}

#[cfg(unix)]
fn set_executable(path: &std::path::Path) {
    use std::os::unix::fs::PermissionsExt;
    let mut permissions = fs::metadata(path).unwrap().permissions();
    permissions.set_mode(0o755);
    fs::set_permissions(path, permissions).unwrap();
}

#[cfg(not(unix))]
fn set_executable(_path: &std::path::Path) {}