qq-cli 0.1.0

A modular Rust QQ task interface for coding agents
use qq_cli::core::domain::Scope;
use qq_cli::messaging::attachments::{self, Kind, Retained};
use qq_cli::requests::ledger::{self, Begin, Outcome};
use qq_cli::storage::references::References;
use qq_cli::storage::state::Store;
use serde_json::json;
use std::time::{Duration, Instant, SystemTime};

fn deadline() -> Instant {
    Instant::now() + Duration::from_secs(5)
}
fn scope() -> Scope {
    Scope {
        instance_id: "instance-example".into(),
        account: "10001".into(),
    }
}
fn prepare(
    temp: &std::path::Path,
    store: &Store,
    refs: &References,
    key: &str,
    outcome: Outcome,
) -> Retained {
    let source = temp.join(format!("{key}.bin"));
    std::fs::write(&source, b"synthetic cached bytes").unwrap();
    let retained = attachments::prepare(&source, Kind::File, store, deadline())
        .unwrap()
        .retain(store, refs, key, &scope(), deadline())
        .unwrap();
    let Begin::Fresh(intent) = ledger::begin(
        store,
        key,
        "send",
        &scope(),
        &json!({"fixture":key}),
        deadline(),
    )
    .unwrap() else {
        panic!("duplicate synthetic request")
    };
    if outcome == Outcome::Pending {
        drop(intent);
    } else {
        intent.finish(outcome, None).unwrap();
    }
    retained
}
fn old() -> SystemTime {
    SystemTime::now() + Duration::from_secs(2 * 24 * 60 * 60)
}

#[test]
fn cache_prune_keeps_uncertain_active_and_young_snapshots_and_is_scope_filtered() {
    let temp = tempfile::tempdir().unwrap();
    let store = Store::new(temp.path().join("state"));
    let refs = References::with_key(temp.path().join("refs"), [88; 32]);
    let accepted = prepare(temp.path(), &store, &refs, "accepted", Outcome::Accepted);
    let rejected = prepare(temp.path(), &store, &refs, "rejected", Outcome::Rejected);
    let pending = prepare(temp.path(), &store, &refs, "pending", Outcome::Pending);
    let unknown = prepare(temp.path(), &store, &refs, "unknown", Outcome::Unknown);
    let young = qq_cli::messaging::attachments::cache::prune(
        &store,
        &refs,
        None,
        SystemTime::now(),
        deadline(),
    )
    .unwrap();
    assert_eq!(
        serde_json::to_value(young).unwrap()["data"]["removedFiles"],
        0
    );
    let other = qq_cli::messaging::attachments::cache::prune(
        &store,
        &refs,
        Some("other-instance"),
        old(),
        deadline(),
    )
    .unwrap();
    assert_eq!(
        serde_json::to_value(other).unwrap()["data"]["removedFiles"],
        0
    );
    let result =
        qq_cli::messaging::attachments::cache::prune(&store, &refs, None, old(), deadline())
            .unwrap();
    let value = serde_json::to_value(result).unwrap();
    let schema = qq_cli::cli::registry::get("media.prune").unwrap();
    assert!(
        jsonschema::validator_for(&schema["responseSchema"])
            .unwrap()
            .is_valid(&value)
    );
    assert_eq!(value["data"]["removedFiles"], 2);
    assert_eq!(value["data"]["preservedEntries"], 2);
    assert!(!accepted.directory().exists());
    assert!(!rejected.directory().exists());
    assert!(pending.path().exists());
    assert!(unknown.path().exists());
    assert!(ledger::get(&store, "accepted").is_ok());
}

#[test]
fn removal_journal_recovers_partial_cleanup_and_never_deletes_unknown_files() {
    let temp = tempfile::tempdir().unwrap();
    let store = Store::new(temp.path().join("state"));
    let refs = References::with_key(temp.path().join("refs"), [89; 32]);
    let interrupted = prepare(temp.path(), &store, &refs, "interrupted", Outcome::Accepted);
    let foreign = prepare(temp.path(), &store, &refs, "foreign", Outcome::Accepted);
    std::fs::write(foreign.directory().join("unowned.bin"), b"must remain").unwrap();
    let journals = store.root().join("attachment-removals");
    qq_cli::storage::state::private_dir(&journals).unwrap();
    let journal = journals.join(format!(
        "{}.json",
        interrupted
            .directory()
            .file_name()
            .unwrap()
            .to_str()
            .unwrap()
    ));
    std::fs::copy(
        interrupted.directory().join(".qq-cli-attachment.json"),
        &journal,
    )
    .unwrap();
    std::fs::remove_file(interrupted.path()).unwrap();
    std::fs::remove_file(interrupted.directory().join(".qq-cli-attachment.json")).unwrap();
    let result =
        qq_cli::messaging::attachments::cache::prune(&store, &refs, None, old(), deadline())
            .unwrap();
    let value = serde_json::to_value(result).unwrap();
    assert_eq!(value["data"]["removedEntries"], 1);
    assert_eq!(value["data"]["preservedEntries"], 1);
    assert!(!journal.exists());
    assert!(!interrupted.directory().exists());
    assert!(foreign.path().exists());
    assert!(foreign.directory().join("unowned.bin").exists());
}

#[test]
fn media_prune_without_any_cache_is_offline_and_does_not_initialize_state() {
    use clap::Parser;
    let temp = tempfile::tempdir().unwrap();
    let config = temp.path().join("config.json");
    std::fs::write(&config, r#"{"stateDir":"state"}"#).unwrap();
    let cli = qq_cli::cli::args::Cli::try_parse_from([
        "qq-cli",
        "media",
        "prune",
        "--config",
        config.to_str().unwrap(),
    ])
    .unwrap();
    let output = qq_cli::app::execute(cli);
    assert_eq!(output.exit_code(), 0);
    assert_eq!(
        serde_json::to_value(output).unwrap()["data"]["removedFiles"],
        0
    );
    assert!(!temp.path().join("state").exists());
}