a3s 0.10.6

a3s — A3S coding agent CLI; `a3s code` launches the interactive TUI
Documentation
use super::*;
use crate::evolution::model::{EvolutionCatalog, EvolutionEvidence};
use crate::evolution::store::{read_catalog, reject_candidate};

fn ready_candidate(id: &str, kind: EvolutionKind) -> EvolutionCandidate {
    let now = Utc::now();
    EvolutionCandidate {
        id: id.to_string(),
        kind,
        pattern_key: "workflow.rust.memory-tests".to_string(),
        pattern_aliases: Vec::new(),
        title: "Focused memory verification".to_string(),
        summary: "Run focused memory verification after persistence changes.".to_string(),
        instructions: vec![
            "Run the focused memory tests.".to_string(),
            "Inspect file-backed persistence results.".to_string(),
        ],
        state: EvolutionState::Ready,
        evidence: vec![EvolutionEvidence {
            id: "evidence-one".to_string(),
            memory_id: "memory-one".to_string(),
            session_id: Some("session-one".to_string()),
            source: "workflow".to_string(),
            content: "Run focused tests after persistence changes.".to_string(),
            reason: Some("Prevents persistence regressions.".to_string()),
            timestamp: now,
            importance: 0.9,
            confidence: 0.95,
            conflicts_with: Vec::new(),
            explicit_signal: true,
        }],
        occurrences: 1,
        distinct_sessions: 1,
        confidence: 0.95,
        importance: 0.9,
        maturity: 0.8,
        has_conflicts: false,
        update_available: false,
        activation_pending: false,
        created_at: now,
        updated_at: now,
        ready_at: Some(now),
        materialized_at: None,
        rejected_at: None,
        rolled_back_at: None,
        rejection_reason: None,
        asset_path: None,
        current_version: None,
        versions: Vec::new(),
        audit: Vec::new(),
    }
}

fn seed(paths: &EvolutionPaths, candidate: EvolutionCandidate) {
    mutate_catalog(paths, |catalog| {
        catalog.candidates.push(candidate);
        Ok(())
    })
    .unwrap();
}

#[test]
fn skill_materialization_is_versioned_and_read_only() {
    let temp = tempfile::tempdir().unwrap();
    let paths = EvolutionPaths::new(temp.path());
    seed(
        &paths,
        ready_candidate("evo-skill-test", EvolutionKind::Skill),
    );
    let result = materialize_candidate(&paths, "evo-skill-test", false, false).unwrap();
    assert!(result.requires_session_reload);
    assert_eq!(result.candidate.current_version, Some(1));
    let asset = temp.path().join(result.candidate.asset_path.unwrap());
    let skill = fs::read_to_string(asset.join("SKILL.md")).unwrap();
    assert!(skill.contains("allowed-tools: \"Read(*), Grep(*), Glob(*), LS(*)\""));
    assert!(asset.join(".a3s/asset.acl").is_file());
    assert!(paths
        .history
        .join("evo-skill-test/v0001/asset/SKILL.md")
        .is_file());
}

#[test]
fn update_refuses_unreviewed_external_edits() {
    let temp = tempfile::tempdir().unwrap();
    let paths = EvolutionPaths::new(temp.path());
    seed(
        &paths,
        ready_candidate("evo-skill-test", EvolutionKind::Skill),
    );
    let first = materialize_candidate(&paths, "evo-skill-test", false, false).unwrap();
    let asset = temp.path().join(first.candidate.asset_path.unwrap());
    fs::write(asset.join("README.md"), "user edit").unwrap();
    mutate_catalog(&paths, |catalog| {
        let candidate = candidate_mut(catalog, "evo-skill-test")?;
        candidate.update_available = true;
        Ok(())
    })
    .unwrap();
    let error = materialize_candidate(&paths, "evo-skill-test", false, false)
        .unwrap_err()
        .to_string();
    assert!(error.contains("changed outside"), "{error}");
}

#[test]
fn rollback_preserves_pre_rollback_recovery_copy() {
    let temp = tempfile::tempdir().unwrap();
    let paths = EvolutionPaths::new(temp.path());
    seed(&paths, ready_candidate("evo-okf-test", EvolutionKind::Okf));
    materialize_candidate(&paths, "evo-okf-test", false, false).unwrap();
    mutate_catalog(&paths, |catalog| {
        let candidate = candidate_mut(catalog, "evo-okf-test")?;
        candidate
            .instructions
            .push("Verify the index links.".to_string());
        candidate.update_available = true;
        Ok(())
    })
    .unwrap();
    materialize_candidate(&paths, "evo-okf-test", false, false).unwrap();
    let result = rollback_candidate(&paths, "evo-okf-test", Some(1)).unwrap();
    assert_eq!(result.candidate.state, EvolutionState::RolledBack);
    assert_eq!(result.candidate.current_version, Some(1));
    assert!(result.recovery_path.is_some());
    assert!(temp.path().join(result.recovery_path.unwrap()).exists());
}

#[test]
fn failed_snapshot_verification_restores_the_pre_rollback_asset() {
    let temp = tempfile::tempdir().unwrap();
    let paths = EvolutionPaths::new(temp.path());
    seed(
        &paths,
        ready_candidate("evo-skill-corrupt-snapshot", EvolutionKind::Skill),
    );
    materialize_candidate(&paths, "evo-skill-corrupt-snapshot", false, false).unwrap();
    mutate_catalog(&paths, |catalog| {
        let candidate = candidate_mut(catalog, "evo-skill-corrupt-snapshot")?;
        candidate
            .instructions
            .push("Run the broader verification after focused checks.".to_string());
        candidate.update_available = true;
        Ok(())
    })
    .unwrap();
    let current =
        materialize_candidate(&paths, "evo-skill-corrupt-snapshot", false, false).unwrap();
    let asset = temp
        .path()
        .join(current.candidate.asset_path.as_deref().unwrap());
    let current_hash = hash_path(&asset).unwrap();
    let first_snapshot = temp
        .path()
        .join(&current.candidate.versions[0].snapshot_path)
        .join("SKILL.md");
    fs::write(&first_snapshot, "corrupted immutable snapshot").unwrap();

    let error = rollback_candidate(&paths, "evo-skill-corrupt-snapshot", Some(1))
        .unwrap_err()
        .to_string();

    assert!(error.contains("immutable v1 snapshot"), "{error}");
    assert_eq!(hash_path(&asset).unwrap(), current_hash);
    let catalog = read_catalog(&paths).unwrap();
    let candidate = catalog
        .candidates
        .iter()
        .find(|candidate| candidate.id == "evo-skill-corrupt-snapshot")
        .unwrap();
    assert_eq!(candidate.state, EvolutionState::Materialized);
    assert_eq!(candidate.current_version, Some(2));
}

#[test]
fn baseline_rollback_removes_the_first_asset_and_can_restore_its_snapshot() {
    let temp = tempfile::tempdir().unwrap();
    let paths = EvolutionPaths::new(temp.path());
    seed(
        &paths,
        ready_candidate("evo-skill-baseline", EvolutionKind::Skill),
    );
    let first = materialize_candidate(&paths, "evo-skill-baseline", false, false).unwrap();
    let asset = temp
        .path()
        .join(first.candidate.asset_path.as_deref().unwrap());
    assert!(asset.is_dir());

    let removed = rollback_candidate(&paths, "evo-skill-baseline", Some(0)).unwrap();

    assert_eq!(removed.candidate.state, EvolutionState::RolledBack);
    assert_eq!(removed.candidate.current_version, None);
    assert!(removed.requires_session_reload);
    assert!(!asset.exists());
    assert!(removed
        .recovery_path
        .as_deref()
        .is_some_and(|path| temp.path().join(path).exists()));

    let restored = rollback_candidate(&paths, "evo-skill-baseline", Some(1)).unwrap();

    assert_eq!(restored.candidate.state, EvolutionState::RolledBack);
    assert_eq!(restored.candidate.current_version, Some(1));
    assert!(restored.requires_session_reload);
    assert!(asset.join("SKILL.md").is_file());
    assert_eq!(
        hash_path(&asset).unwrap(),
        first.candidate.versions[0].content_hash
    );
}

#[test]
fn rejection_requires_a_rolled_back_candidate_to_have_no_active_version() {
    let temp = tempfile::tempdir().unwrap();
    let paths = EvolutionPaths::new(temp.path());
    seed(
        &paths,
        ready_candidate("evo-reject-baseline", EvolutionKind::Okf),
    );
    materialize_candidate(&paths, "evo-reject-baseline", false, false).unwrap();
    mutate_catalog(&paths, |catalog| {
        let candidate = candidate_mut(catalog, "evo-reject-baseline")?;
        candidate
            .instructions
            .push("Verify the restored snapshot before reuse.".to_string());
        candidate.update_available = true;
        Ok(())
    })
    .unwrap();
    materialize_candidate(&paths, "evo-reject-baseline", false, false).unwrap();
    let rolled_back = rollback_candidate(&paths, "evo-reject-baseline", Some(1)).unwrap();
    assert_eq!(rolled_back.candidate.state, EvolutionState::RolledBack);
    assert_eq!(rolled_back.candidate.current_version, Some(1));

    let error = reject_candidate(&paths, "evo-reject-baseline", None)
        .unwrap_err()
        .to_string();
    assert!(error.contains("active materialized version"), "{error}");

    rollback_candidate(&paths, "evo-reject-baseline", Some(0)).unwrap();
    let rejected = reject_candidate(
        &paths,
        "evo-reject-baseline",
        Some("Keep this pattern out of future suggestions.".to_string()),
    )
    .unwrap();
    assert_eq!(rejected.state, EvolutionState::Rejected);
    assert_eq!(rejected.current_version, None);
}

#[test]
fn catalog_seed_uses_current_schema() {
    let temp = tempfile::tempdir().unwrap();
    let paths = EvolutionPaths::new(temp.path());
    let catalog = EvolutionCatalog::empty(temp.path().display().to_string());
    assert_eq!(catalog.schema, super::super::model::EVOLUTION_SCHEMA);
    assert!(!paths.state.exists());
}