qqq-cli 0.3.0

Local-first task queue for coding agent sessions
#[allow(dead_code)]
#[path = "../src/db.rs"]
mod db;
#[allow(dead_code)]
#[path = "../src/tui/draft.rs"]
pub mod draft;
#[allow(dead_code)]
#[path = "../src/herdr.rs"]
mod herdr;
#[allow(dead_code)]
#[path = "../src/identity.rs"]
mod identity;
#[allow(dead_code)]
#[path = "../src/images.rs"]
mod images;
#[allow(dead_code)]
#[path = "../src/sql_filter/mod.rs"]
mod sql_filter;
mod tui {
    pub use crate::draft;
}

use db::Db;
use draft::{Composition, Draft};
use images::{ImageInput, ImageStore};
use std::process::{Command, Stdio};

fn database() -> (Db, tempfile::TempDir) {
    let dir = tempfile::TempDir::new().unwrap();
    let conn = rusqlite::Connection::open(dir.path().join("qqq.db")).unwrap();
    conn.execute_batch(include_str!("../src/schema.sql"))
        .unwrap();
    conn.execute_batch(include_str!("../src/migrate_v2.sql"))
        .unwrap();
    conn.execute_batch(include_str!("../src/migrate_v3.sql"))
        .unwrap();
    conn.execute_batch(include_str!("../src/migrate_v4.sql"))
        .unwrap();
    conn.execute_batch(include_str!("../src/migrate_v5.sql"))
        .unwrap();
    conn.execute_batch(include_str!("../src/migrate_v6.sql"))
        .unwrap();
    conn.execute_batch(include_str!("../src/migrate_v7.sql"))
        .unwrap();
    conn.execute_batch(include_str!("../src/migrate_v8.sql"))
        .unwrap();
    conn.execute_batch(include_str!("../src/migrate_v9.sql"))
        .unwrap();
    (
        Db {
            conn,
            image_store: ImageStore::new(dir.path().join("images")),
        },
        dir,
    )
}
fn composition() -> Composition {
    Composition {
        description: "Details".into(),
        images: vec![ImageInput {
            name: "ok.png".into(),
            data: b"\x89PNG\r\n\x1a\n".to_vec(),
        }],
        image_spans: Vec::new(),
    }
}

#[test]
fn task_messages_preserve_body_author_order_and_show_contract() {
    let (mut db, dir) = database();
    let task = db.add("Selected", None, &[]).unwrap();
    assert!(db.task_messages(task.id).unwrap().is_empty());
    db.message(task.id, "First\nDetails", Some("worker"))
        .unwrap();
    db.message(task.id, "Second", None).unwrap();
    let messages = db.task_messages(task.id).unwrap();
    assert_eq!(messages.len(), 2);
    assert!(messages[0].id < messages[1].id);
    assert_eq!(messages[0].body, "First\nDetails");
    assert_eq!(messages[0].session.as_deref(), Some("worker"));
    assert!(messages[1].session.is_none());
    assert!(messages[0].created_at.ends_with('Z'));
    assert_eq!(
        db.show(task.id).unwrap()["messages"],
        serde_json::to_value(messages).unwrap()
    );
    assert_eq!(db.show_task(&task).unwrap(), db.show(task.id).unwrap());
    // Simulate deletion after TUI listed task, before reading its detail sections.
    let task = db.list(None).unwrap().pop().unwrap();
    let version = db.data_version().unwrap();
    let external = rusqlite::Connection::open(dir.path().join("qqq.db")).unwrap();
    external
        .execute("DELETE FROM messages WHERE task_id=?", [task.id])
        .unwrap();
    external
        .execute("DELETE FROM tasks WHERE id=?", [task.id])
        .unwrap();
    assert!(db.show(task.id).is_err());
    let snapshot = db.show_task(&task).unwrap();
    assert_eq!(snapshot["task"]["id"], task.id);
    assert_eq!(snapshot["task"]["description"], "Selected");
    for section in ["messages", "images", "events"] {
        assert_eq!(snapshot[section], serde_json::json!([]));
    }
    assert!(snapshot["herdr"].is_null());
    assert_ne!(db.data_version().unwrap(), version);
    assert!(db.list(None).unwrap().is_empty());
}

#[test]
fn task_navigation_skips_archived_unless_included() {
    let (mut db, _dir) = database();
    db.add("First", None, &[]).unwrap();
    db.add("Hidden", None, &[]).unwrap();
    db.add("Third", None, &[]).unwrap();
    db.set_archived(2, true, "cli").unwrap();
    assert_eq!(db.adjacent_task(None, true, false).unwrap().unwrap().0, 3);
    assert_eq!(
        db.adjacent_task(Some(3), true, false).unwrap().unwrap().0,
        1
    );
    assert_eq!(
        db.adjacent_task(Some(1), false, false).unwrap().unwrap().0,
        3
    );
    assert_eq!(db.adjacent_task(Some(3), true, true).unwrap().unwrap().0, 2);
}

#[test]
fn legacy_open_keeps_foreign_keys_enabled() {
    if std::env::var_os("QQQ_TEST_LEGACY_FK_CHILD").is_none() {
        return;
    }
    let (db, _) = Db::open(false).unwrap();
    let enabled: i64 = db
        .conn
        .pragma_query_value(None, "foreign_keys", |row| row.get(0))
        .unwrap();
    assert_eq!(enabled, 1);
}

#[test]
fn concurrent_legacy_opens_keep_foreign_keys_enabled() {
    let dir = tempfile::TempDir::new().unwrap();
    std::fs::create_dir(dir.path().join(".qqq")).unwrap();
    let conn = rusqlite::Connection::open(dir.path().join(".qqq/qqq.db")).unwrap();
    for migration in [
        include_str!("../src/schema.sql"),
        include_str!("../src/migrate_v2.sql"),
        include_str!("../src/migrate_v3.sql"),
        include_str!("../src/migrate_v4.sql"),
        include_str!("../src/migrate_v5.sql"),
    ] {
        conn.execute_batch(migration).unwrap();
    }
    conn.execute("INSERT INTO tasks(description) VALUES ('Legacy')", [])
        .unwrap();
    conn.execute(
        "INSERT INTO images(task_id,name,media_type,data) VALUES (1,'legacy.png','image/png',?1)",
        [vec![1_u8; 4 * 1024 * 1024]],
    )
    .unwrap();
    drop(conn);

    let children: Vec<_> = (0..6)
        .map(|_| {
            Command::new(std::env::current_exe().unwrap())
                .args(["--exact", "legacy_open_keeps_foreign_keys_enabled"])
                .env("QQQ_TEST_LEGACY_FK_CHILD", "1")
                .current_dir(dir.path())
                .stdout(Stdio::piped())
                .stderr(Stdio::piped())
                .spawn()
                .unwrap()
        })
        .collect();
    for child in children {
        let output = child.wait_with_output().unwrap();
        assert!(
            output.status.success(),
            "{}{}",
            String::from_utf8_lossy(&output.stdout),
            String::from_utf8_lossy(&output.stderr)
        );
    }
}
#[test]
fn pasted_images_get_stored_markdown_paths_after_image_ids_are_allocated() {
    let (mut db, dir) = database();
    let mut draft = Draft::new("See ");
    draft
        .image(ImageInput {
            name: "a]b.png".into(),
            data: b"\x89PNG\r\n\x1a\n".to_vec(),
        })
        .unwrap();
    draft
        .image(ImageInput {
            name: "second.png".into(),
            data: b"\x89PNG\r\n\x1a\n".to_vec(),
        })
        .unwrap();
    let mut composition = draft.finish().unwrap();
    composition.images.push(ImageInput {
        name: "flag.png".into(),
        data: b"\x89PNG\r\n\x1a\n".to_vec(),
    });
    let task = db.save_composition(None, None, &composition).unwrap();
    assert_eq!(
        task.description,
        "See ![a\\]b.png](.qqq/images/1/1.png)![second.png](.qqq/images/1/2.png)"
    );
    assert_eq!(
        db.show(task.id).unwrap()["images"]
            .as_array()
            .unwrap()
            .len(),
        3
    );
    let references = db.image_references(task.id).unwrap();
    assert_eq!(
        (references[0].id, references[0].name.as_str()),
        (1, "a]b.png")
    );
    assert_eq!(
        (references[1].id, references[1].name.as_str()),
        (2, "second.png")
    );
    assert_eq!(
        (references[2].id, references[2].name.as_str()),
        (3, "flag.png")
    );
    assert!(dir.path().join("images/1/1.png").exists());
    assert!(dir.path().join("images/1/2.png").exists());
    assert!(dir.path().join("images/1/3.png").exists());

    let mut edit = Draft::new(&task.description);
    edit.image(ImageInput {
        name: "third.png".into(),
        data: b"\x89PNG\r\n\x1a\n".to_vec(),
    })
    .unwrap();
    let updated = db
        .save_composition(Some(task.id), None, &edit.finish().unwrap())
        .unwrap();
    assert_eq!(
        updated.description,
        "See ![a\\]b.png](.qqq/images/1/1.png)![second.png](.qqq/images/1/2.png)![third.png](.qqq/images/1/4.png)"
    );
}
#[test]
fn failed_pasted_image_save_rolls_back_link_and_file() {
    let (mut db, dir) = database();
    let task = db.add("Original", None, &[]).unwrap();
    db.conn.execute_batch("CREATE TRIGGER reject_image BEFORE INSERT ON images WHEN NEW.name='fail.png' BEGIN SELECT RAISE(ABORT,'image rejected'); END;").unwrap();
    let mut draft = Draft::new("Changed ");
    for name in ["ok.png", "fail.png"] {
        draft
            .image(ImageInput {
                name: name.into(),
                data: b"\x89PNG\r\n\x1a\n".to_vec(),
            })
            .unwrap();
    }
    assert!(
        db.save_composition(Some(task.id), None, &draft.finish().unwrap())
            .is_err()
    );
    assert_eq!(db.task(task.id).unwrap().description, "Original");
    assert!(db.image_references(task.id).unwrap().is_empty());
    assert!(!dir.path().join("images/1/1.png").exists());
}
#[test]
fn composition_saves_task_and_image_bytes_together() {
    let (mut db, dir) = database();
    let task = db.save_composition(None, None, &composition()).unwrap();
    assert_eq!(task.description, "Details");
    assert_eq!(db.show(task.id).unwrap()["images"][0]["name"], "ok.png");
    let bytes = std::fs::read(dir.path().join("images/1/1.png")).unwrap();
    assert_eq!(bytes, b"\x89PNG\r\n\x1a\n");
}
#[test]
fn composition_saves_pasteboard_block_and_image_link_together() {
    let (mut db, _dir) = database();
    let payload = format!("```\n{}", "x".repeat(1001));
    let mut draft = Draft::new("Before ");
    draft.paste(&payload);
    draft
        .image(ImageInput {
            name: "after.png".into(),
            data: b"\x89PNG\r\n\x1a\n".to_vec(),
        })
        .unwrap();
    let task = db
        .save_composition(None, None, &draft.finish().unwrap())
        .unwrap();
    assert_eq!(
        task.description,
        format!("Before \n````pasteboard\n{payload}\n````\n![after.png](.qqq/images/1/1.png)")
    );
    let loaded = Draft::from_saved(
        &task.description,
        task.id,
        &db.image_references(task.id).unwrap(),
    )
    .unwrap();
    assert!(
        loaded
            .fragments()
            .iter()
            .any(|part| part.starts_with("[Pasted Content "))
    );
    assert!(
        loaded
            .fragments()
            .iter()
            .any(|part| part == "[Image #1: after.png]")
    );
    assert_eq!(loaded.finish().unwrap().description, task.description);
}
#[test]
fn failed_image_insert_rolls_back_new_and_edited_drafts() {
    let (mut db, _dir) = database();
    db.conn.execute_batch("CREATE TRIGGER reject_image BEFORE INSERT ON images WHEN NEW.name='fail.png' BEGIN SELECT RAISE(ABORT,'image rejected'); END;").unwrap();
    let mut draft = composition();
    draft.images.push(ImageInput {
        name: "fail.png".into(),
        data: b"\x89PNG\r\n\x1a\n".to_vec(),
    });
    assert!(db.save_composition(None, None, &draft).is_err());
    assert!(db.list(None).unwrap().is_empty());
    let existing = db.add("Original\n\nOriginal details", None, &[]).unwrap();
    let before = db.show(existing.id).unwrap();
    assert!(
        db.save_composition(Some(existing.id), None, &draft)
            .is_err()
    );
    assert_eq!(db.show(existing.id).unwrap(), before);
}
#[test]
fn draft_edit_preserves_ownership_dependencies_and_existing_attachments() {
    let (mut db, _dir) = database();
    let parent = db.add("Parent", None, &[]).unwrap();
    db.next("parent", None).unwrap();
    db.complete(parent.id, "parent", None).unwrap();
    let task = db
        .save_composition(None, Some(parent.id), &composition())
        .unwrap();
    db.next("worker", None).unwrap();
    db.message(task.id, "Note", Some("worker")).unwrap();
    let updated = db
        .save_composition(Some(task.id), None, &composition())
        .unwrap();
    assert_eq!(updated.status, "in_progress");
    assert_eq!(updated.identity.harness_session.as_deref(), Some("worker"));
    assert_eq!(updated.parent_id, Some(parent.id));
    let shown = db.show(task.id).unwrap();
    assert_eq!(shown["images"].as_array().unwrap().len(), 2);
    assert_eq!(shown["messages"][0]["body"], "Note");
}