clawgallery 0.2.3

Agent-native screenshot gallery CLI with JSONL metadata, visual captions, and safe rename workflows.
use std::{
    env, fs,
    path::{Path, PathBuf},
    process::{Command, Output},
};

fn bin() -> PathBuf {
    PathBuf::from(env!("CARGO_BIN_EXE_clawgallery"))
}

fn run(config: &Path, args: &[&str]) -> Output {
    Command::new(bin())
        .env("CLAWGALLERY_CONFIG_DIR", config)
        .env_remove("OPENAI_API_KEY")
        .env("CODEX_HOME", config.join("codex-home"))
        .args(args)
        .output()
        .expect("clawgallery command should run")
}

fn assert_success(output: Output) -> String {
    if !output.status.success() {
        panic!(
            "command failed\nstdout:\n{}\nstderr:\n{}",
            String::from_utf8_lossy(&output.stdout),
            String::from_utf8_lossy(&output.stderr)
        );
    }
    String::from_utf8_lossy(&output.stdout).to_string()
}

fn inject_caption(config: &Path, image_path: &Path, image_id: &str, title: &str) {
    let record = serde_json::json!({
        "image_id": image_id,
        "path": image_path,
        "title": title,
        "description": format!("test description for {title}"),
        "model": "test",
        "provider": "test",
        "created_at": "2026-05-04T00:00:00Z",
        "filename_meaningful": false,
    });
    let mut file = fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(config.join("captions.jsonl"))
        .unwrap();
    use std::io::Write;
    writeln!(file, "{record}").unwrap();
}

fn first_image_id(config: &Path) -> String {
    let line = fs::read_to_string(config.join("images.jsonl")).unwrap();
    let rec: serde_json::Value = serde_json::from_str(line.lines().next().unwrap()).unwrap();
    rec["id"].as_str().unwrap().to_string()
}

fn setup_search_fixture(
    entries: &[(&str, &str, &str, &str, bool)],
) -> (tempfile::TempDir, PathBuf) {
    let temp = tempfile::tempdir().unwrap();
    let config = temp.path().join("state");
    let images = temp.path().join("images");
    fs::create_dir_all(&images).unwrap();
    assert_success(run(&config, &["init"]));

    let mut image_lines = String::new();
    let mut caption_lines = String::new();
    for (idx, (name, title, description, discovered_at, active)) in entries.iter().enumerate() {
        let path = images.join(name);
        fs::write(&path, b"not really png").unwrap();
        let canonical = path.canonicalize().unwrap();
        let id = format!("image-{idx}");
        let image = serde_json::json!({
            "id": id,
            "path": canonical,
            "original_path": canonical,
            "sha256": format!("sha{idx}"),
            "size": 1,
            "modified_at": null,
            "discovered_at": discovered_at,
            "extension": "png",
            "active": active,
            "removed_at": null,
        });
        image_lines.push_str(&format!("{image}\n"));
        let caption = serde_json::json!({
            "image_id": id,
            "path": canonical,
            "title": title,
            "description": description,
            "model": "test",
            "provider": "test",
            "created_at": "2026-05-04T00:00:00Z",
            "filename_meaningful": false
        });
        caption_lines.push_str(&format!("{caption}\n"));
    }
    fs::write(config.join("images.jsonl"), image_lines).unwrap();
    fs::write(config.join("captions.jsonl"), caption_lines).unwrap();
    (temp, config)
}

#[test]
fn folder_add_duplicate_is_not_noisy() {
    let temp = tempfile::tempdir().unwrap();
    let config = temp.path().join("state");
    let images = temp.path().join("images");
    fs::create_dir_all(&images).unwrap();

    assert_success(run(&config, &["folder", "add", images.to_str().unwrap()]));
    let duplicate = assert_success(run(&config, &["folder", "add", images.to_str().unwrap()]));

    assert_eq!(
        duplicate.trim(),
        format!(
            "folder already tracked: {}",
            images.canonicalize().unwrap().display()
        )
    );
}

#[test]
fn bootstrap_unknown_folder_id_is_an_error() {
    let temp = tempfile::tempdir().unwrap();
    let config = temp.path().join("state");
    let images = temp.path().join("images");
    fs::create_dir_all(&images).unwrap();

    assert_success(run(&config, &["folder", "add", images.to_str().unwrap()]));
    let output = run(&config, &["bootstrap", "--folder", "missing-folder-id"]);

    assert!(!output.status.success());
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        stderr.contains("no active folder matched 'missing-folder-id'"),
        "unknown folder id must fail clearly, got: {stderr}"
    );
}

#[test]
fn rename_dry_run_does_not_write_rename_records() {
    let temp = tempfile::tempdir().unwrap();
    let config = temp.path().join("state");
    let images = temp.path().join("images");
    fs::create_dir_all(&images).unwrap();
    let image = images.join("IMG_0099.png");
    fs::write(&image, b"not really png").unwrap();

    assert_success(run(&config, &["init"]));
    assert_success(run(
        &config,
        &["bootstrap", "--path", images.to_str().unwrap()],
    ));
    let canonical = image.canonicalize().unwrap();
    let id = first_image_id(&config);
    inject_caption(&config, &canonical, &id, "Dry Run Title");

    let dry_run = assert_success(run(&config, &["rename", "--dry-run", "--style", "title"]));

    assert!(dry_run.contains("dry-run"));
    assert_eq!(
        fs::read_to_string(config.join("renames.jsonl")).unwrap(),
        "",
        "rename dry-run must not mutate renames.jsonl"
    );
}

#[test]
fn rename_undo_dry_run_does_not_write_rename_records() {
    let temp = tempfile::tempdir().unwrap();
    let config = temp.path().join("state");
    let images = temp.path().join("images");
    fs::create_dir_all(&images).unwrap();
    let original = images.join("IMG_0111.png");
    fs::write(&original, b"x").unwrap();
    assert_success(run(&config, &["init"]));
    assert_success(run(&config, &["folder", "add", images.to_str().unwrap()]));
    assert_success(run(&config, &["bootstrap"]));
    let canonical = original.canonicalize().unwrap();
    let id = first_image_id(&config);
    inject_caption(&config, &canonical, &id, "Undo Dry Run");
    assert_success(run(&config, &["rename", "--apply", "--style", "title"]));
    let before = fs::read_to_string(config.join("renames.jsonl")).unwrap();

    let dry_run = assert_success(run(&config, &["rename", "--undo", "--last", "--dry-run"]));

    assert!(dry_run.contains("would undo"));
    assert_eq!(
        fs::read_to_string(config.join("renames.jsonl")).unwrap(),
        before,
        "rename undo dry-run must not mutate renames.jsonl"
    );
}

#[test]
fn caption_rejects_zero_concurrency() {
    let temp = tempfile::tempdir().unwrap();
    let config = temp.path().join("state");
    assert_success(run(&config, &["init"]));

    let output = run(&config, &["caption", "--dry-run", "--concurrency", "0"]);

    assert!(!output.status.success());
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        stderr.contains("--concurrency must be at least 1"),
        "zero concurrency should fail clearly, got: {stderr}"
    );
}

#[test]
fn poll_rejects_zero_interval() {
    let temp = tempfile::tempdir().unwrap();
    let config = temp.path().join("state");
    assert_success(run(&config, &["init"]));

    let output = run(&config, &["poll", "--once", "--interval", "0"]);

    assert!(!output.status.success());
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        stderr.contains("--interval must be at least 1"),
        "zero interval should fail clearly, got: {stderr}"
    );
}

#[test]
fn search_rejects_zero_limit() {
    let (_temp, config) =
        setup_search_fixture(&[("one.png", "Settings", "panel", "2026-05-04T00:00:00Z", true)]);

    let output = run(&config, &["search", "settings", "--limit", "0"]);

    assert!(!output.status.success());
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        stderr.contains("--limit must be at least 1"),
        "zero limit should fail clearly, got: {stderr}"
    );
}

#[test]
fn status_fails_on_corrupt_config() {
    let temp = tempfile::tempdir().unwrap();
    let config = temp.path().join("state");
    assert_success(run(&config, &["init"]));
    fs::write(config.join("config.json"), "{not json").unwrap();

    let output = run(&config, &["status"]);

    assert!(!output.status.success());
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        stderr.contains("config.json"),
        "corrupt config should be surfaced, got: {stderr}"
    );
}

#[test]
fn malformed_jsonl_state_fails_loudly() {
    let temp = tempfile::tempdir().unwrap();
    let config = temp.path().join("state");
    assert_success(run(&config, &["init"]));
    fs::write(config.join("images.jsonl"), "{bad json\n").unwrap();

    let output = run(&config, &["status"]);

    assert!(!output.status.success());
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        stderr.contains("images.jsonl") && stderr.contains("line 1"),
        "malformed state line should identify the file and line, got: {stderr}"
    );
}

#[test]
fn dedup_rejects_exact_and_similar_together() {
    let temp = tempfile::tempdir().unwrap();
    let config = temp.path().join("state");
    assert_success(run(&config, &["init"]));

    let output = run(&config, &["dedup", "--exact", "--similar"]);

    assert!(!output.status.success());
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        stderr.contains("--exact and --similar cannot be used together"),
        "conflicting dedup modes should fail clearly, got: {stderr}"
    );
}