afterautism-cli 2.0.0

Headless command-line interface for the AfterAutism engine
// SPDX-License-Identifier: AGPL-3.0-or-later
// SPDX-FileCopyrightText: 2026 afterautism project contributors
//
//! `afterautism` — headless CLI for the engine.
//!
//! Exercises the full engine end-to-end without a GUI: create a corpus,
//! ingest files through reference adapters, query with the query
//! language, export records, back up and restore.

use afterautism_adapter::Adapter;
use afterautism_adapter_markdown::MarkdownAdapter;
use afterautism_adapter_records::RecordsAdapter;
use afterautism_ingest::IngestCoordinator;
use afterautism_query::QueryExpr;
use afterautism_storage::{Corpus, StagingCorpus};
use clap::{Parser, Subcommand};
use std::path::{Path, PathBuf};

#[derive(Parser)]
#[command(name = "afterautism", version, about = "AfterAutism engine CLI")]
struct Cli {
    #[command(subcommand)]
    command: Command,
}

#[derive(Subcommand)]
enum Command {
    /// Create a new corpus at PATH.
    Create { path: PathBuf },
    /// Ingest FILES into the corpus at PATH.
    Ingest {
        #[arg(long)]
        corpus: PathBuf,
        #[arg(required = true)]
        files: Vec<PathBuf>,
    },
    /// Query the corpus at PATH with QUERY.
    Query {
        #[arg(long)]
        corpus: PathBuf,
        #[arg(long, default_value_t = 20)]
        limit: usize,
        query: String,
    },
    /// Export all nodes as JSONL to STDOUT.
    Export {
        #[arg(long)]
        corpus: PathBuf,
    },
    /// Back up the corpus at PATH to DEST.
    Backup {
        #[arg(long)]
        corpus: PathBuf,
        dest: PathBuf,
    },
    /// Restore the corpus at PATH from SRC.
    Restore {
        #[arg(long)]
        corpus: PathBuf,
        src: PathBuf,
    },
}

fn main() {
    let cli = Cli::parse();
    let result = match cli.command {
        Command::Create { path } => cmd_create(&path),
        Command::Ingest { corpus, files } => cmd_ingest(&corpus, &files),
        Command::Query {
            corpus,
            limit,
            query,
        } => cmd_query(&corpus, &query, limit),
        Command::Export { corpus } => cmd_export(&corpus),
        Command::Backup { corpus, dest } => cmd_backup(&corpus, &dest),
        Command::Restore { corpus, src } => cmd_restore(&corpus, &src),
    };
    if let Err(e) = result {
        eprintln!("error: {e}");
        std::process::exit(1);
    }
}

fn cmd_create(path: &Path) -> Result<(), Box<dyn std::error::Error>> {
    let staging = StagingCorpus::create(path)?;
    // Commit an empty staging so the corpus file exists at PATH.
    let live = PathBuf::from(format!("{}.live", path.display()));
    staging.commit_to(&live)?;
    println!("created corpus: {}", path.display());
    Ok(())
}

fn cmd_ingest(corpus: &Path, files: &[PathBuf]) -> Result<(), Box<dyn std::error::Error>> {
    let mut corpus = Corpus::open(corpus)?;
    let coord = IngestCoordinator::new();

    let mut total_nodes = 0usize;
    let mut total_edges = 0usize;
    for file in files {
        let adapter = pick_adapter(file)?;
        // Per-adapter id namespace: each adapter owns a reserved range so
        // batches from different adapters never collide in one corpus.
        let start_id = namespace_for(adapter.id());
        let source = afterautism_ingest::Source::with_meta(
            file.display().to_string(),
            afterautism_ingest::SourceMeta::default(),
        );
        // Single ingest: the coordinator returns both the report and the
        // batch; we write the batch to the live corpus.
        let (report, batch) = coord.ingest_once_with_batch(&*adapter, &source, None, "cli")?;
        // Shift every node/edge id into the adapter's namespace.
        let shifted = shift_batch(batch, start_id);
        corpus.write_batch(&shifted, "cli")?;
        total_nodes += report.nodes;
        total_edges += report.edges;
        println!(
            "ingested {}: {} nodes, {} edges",
            file.display(),
            report.nodes,
            report.edges
        );
    }
    println!("total: {total_nodes} nodes, {total_edges} edges in {corpus:?}");
    Ok(())
}

fn cmd_query(corpus: &Path, query: &str, limit: usize) -> Result<(), Box<dyn std::error::Error>> {
    let corpus = Corpus::open(corpus)?;
    let expr = QueryExpr::parse(query).map_err(|e| e.to_string())?;
    let opts = afterautism_query::ExecOptions {
        limit: Some(limit),
        after: None,
    };
    let result = afterautism_query::exec::execute(&corpus, &expr, &opts)?;
    println!(
        "matched {} total (showing {})",
        result.total.unwrap_or(0),
        result.node_ids.len()
    );
    for id in &result.node_ids {
        if let Some(node) = corpus.get_node(*id)? {
            println!("  [{:?}] {}", id, node.label);
        }
    }
    Ok(())
}

fn cmd_export(corpus: &Path) -> Result<(), Box<dyn std::error::Error>> {
    let corpus = Corpus::open(corpus)?;
    let mut cursor = None;
    loop {
        let (page, next) = corpus.page_nodes(cursor, 500)?;
        for node in &page {
            let rec = serde_json::json!({
                "id": node.id.to_raw(),
                "label": node.label,
                "kind": node.kind.as_str(),
            });
            println!("{}", serde_json::to_string(&rec)?);
        }
        match next {
            Some(n) => cursor = Some(n),
            None => break,
        }
    }
    Ok(())
}

fn cmd_backup(corpus: &Path, dest: &Path) -> Result<(), Box<dyn std::error::Error>> {
    let corpus = Corpus::open(corpus)?;
    corpus.backup_to(dest)?;
    println!("backed up to {}", dest.display());
    Ok(())
}

fn cmd_restore(corpus: &Path, src: &Path) -> Result<(), Box<dyn std::error::Error>> {
    let mut corpus = Corpus::open(corpus)?;
    corpus.restore_from(src)?;
    println!("restored from {}", src.display());
    Ok(())
}

/// A reserved id range base per adapter (adapter id << 20).
fn namespace_for(adapter_id: afterautism_core::AdapterId) -> u64 {
    (adapter_id.to_raw() & 0xFFFF) << 20
}

/// Shift every id in the batch by `base` so distinct adapters never collide.
fn shift_batch(
    batch: afterautism_adapter::IngestBatch,
    base: u64,
) -> afterautism_adapter::IngestBatch {
    let nodes = batch
        .nodes
        .into_iter()
        .map(|mut n| {
            n.id = afterautism_core::NodeId::from_raw(n.id.to_raw() + base);
            n
        })
        .collect();
    let edges = batch
        .edges
        .into_iter()
        .map(|mut e| {
            e.from = afterautism_core::NodeId::from_raw(e.from.to_raw() + base);
            e.to = afterautism_core::NodeId::from_raw(e.to.to_raw() + base);
            e
        })
        .collect();
    afterautism_adapter::IngestBatch { nodes, edges }
}

/// Pick the reference adapter by file extension.
fn pick_adapter(path: &std::path::Path) -> Result<Box<dyn Adapter>, String> {
    let ext = path
        .extension()
        .and_then(|e| e.to_str())
        .unwrap_or_default()
        .to_ascii_lowercase();
    match ext.as_str() {
        "csv" | "jsonl" | "ndjson" => Ok(Box::new(RecordsAdapter)),
        "md" | "markdown" => Ok(Box::new(MarkdownAdapter)),
        other => Err(format!(
            "no reference adapter for .{other} (try .csv/.jsonl/.md)"
        )),
    }
}