aristo-cli 0.3.0

Aristo CLI binary (the `aristo` command).
Documentation
//! `aristo init` — bootstrap a project for Aristo.
//!
//! Creates the project state (`aristo.toml`, `.aristo/specs/`, `.aristo/doc/`),
//! seeds a local `.aristo/index.toml` cache, and gitignores the runtime/per-user
//! `.aristo/` artifacts (cache, sessions, queues, nudge state, critiques,
//! archive). The pre-commit hook is deprecated and opt-in (`--hook`), never
//! auto-installed. CI workflows are opt-in: `--ci` writes the lite PR gate,
//! `--ci-verify` also writes the nightly verify workflow.
//!
//! The index is a regenerable local cache (index-as-local-cache / Option B):
//! read commands regenerate it from source + `.aristo/proofs/` on demand, so
//! they do NOT require `aristo stamp` to have run or the file to exist.

use std::collections::BTreeMap;
use std::fs;
use std::path::Path;

use aristo_core::config::ConfigFile;
use aristo_core::index::{IndexFile, Meta};

use crate::{CliError, CliResult};

/// The pre-commit hook (DEPRECATED; opt-in only via `aristo init --hook`).
///
/// Runs three steps in order:
///
/// 1. `aristo stamp` — refresh `.aristo/index.toml` against
///    currently-staged source.
/// 2. `aristo doc` — regenerate `.aristo/doc/<id>.md` files so they
///    stay in sync with the index (CI's `doc --check` gate fails
///    without this step on every annotation-text edit).
/// 3. `aristo lint --check` — fail-fast on annotation quality
///    issues. Mirrors J6's default `pre_commit = "check"` mode.
///
/// This hook is an OPTIONAL convenience, not the enforcement boundary: the
/// index is a gitignored local cache, so freshness is enforced in CI by
/// `aristo verify --audit`, not here. If `aristo` is not on `PATH` (e.g. a
/// fork checkout where the contributor never ran `cargo install aristo-cli`),
/// the hook skips silently (`command -v` guard) rather than aborting every
/// commit with `command not found`.
///
/// When the binary IS present, the `-e` shell option propagates any non-zero
/// exit from `doc` or `lint --check` as the hook's exit code.
const PRE_COMMIT_HOOK: &str = "\
#!/usr/bin/env bash
# Aristo pre-commit hook (DEPRECATED; opt-in, installed only by `aristo init --hook`).
# The index is a gitignored local cache, so this hook is NOT an enforcement
# boundary — CI (`aristo verify --audit`) is. It refreshes the local cache and
# the committed .aristo/doc/<id>.md artifacts, then lints annotation prose.
# Skips silently when the aristo binary is absent (e.g. a fork checkout).
command -v aristo >/dev/null 2>&1 || exit 0
set -e

echo \"-> aristo stamp (refresh local cache)\" >&2
aristo stamp >&2

echo \"-> aristo doc\" >&2
aristo doc >&2

echo \"-> aristo lint --check\" >&2
aristo lint --check >&2
";

const GH_WORKFLOW_STARTER: &str = "\
# Aristo lite PR gate (starter; edit freely). Generated by `aristo init --ci`.
#
# Runs the cheap, local annotation gate — audit/lint/doc — via the shared
# aristo-action. No token needed; runs entirely on the runner. `audit` maps to
# `aristo verify --audit --strict`: it regenerates the index from source +
# .aristo/proofs/ (the index is a gitignored cache, never committed) and reds
# on any stale (code drifted from its proof), refuted, or orphan proof. A fresh
# repo has no such debt, so --strict is safe here.
#
# Heavy canon verification (`aristo verify`) is a separate workflow: run
# `aristo init --ci-verify` to also generate aristo-verify.yml.
name: aristo
on: [push, pull_request]
jobs:
  aristo:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: aretta-ai/aristo-action@v1
        with:
          checks: audit, lint, doc
";

const GH_VERIFY_WORKFLOW: &str = "\
# Aristo heavy verification (starter; edit freely).
# Generated by `aristo init --ci-verify`.
#
# Runs `aristo verify` (server-side canon conformance) for the bound
# annotations in source (the index is regenerated from source on demand).
# Manual + nightly.
#
# Setup: add a repository secret ARETTA_TOKEN (your arta_* token; paid tier).
# NOT on pull_request — verify needs the checked-out commit pushed to origin.
name: aristo verify
on:
  workflow_dispatch:
  schedule:
    - cron: '0 7 * * *' # nightly at 07:00 UTC
jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: aretta-ai/aristo-action@v1
        with:
          checks: verify
          aretta-token: ${{ secrets.ARETTA_TOKEN }}
";

#[aristo::intent(
    "A second invocation never errors and never overwrites existing \
     files. Each pre-existing artifact is noted; only missing ones get \
     created.",
    verify = "test",
    id = "init_is_idempotent"
)]
pub(crate) fn run(_force: bool, ci: bool, ci_verify: bool, hook: bool) -> CliResult<()> {
    let cwd = std::env::current_dir()?;
    let mut any_change = false;

    // 1. aristo.toml at the workspace root.
    create_or_note_file(
        &cwd.join("aristo.toml"),
        "aristo.toml",
        serialize_default_config,
        &mut any_change,
    )?;

    // 2. .aristo/ directory must exist before its children.
    let aristo_dir = cwd.join(".aristo");
    fs::create_dir_all(&aristo_dir)?;

    // 3. .aristo/index.toml — seed the local cache (gitignored below). Readers
    //    regenerate it from source + proofs, so this is just a fast-path seed.
    create_or_note_file_custom_msg(
        &aristo_dir.join("index.toml"),
        "ok: created .aristo/index.toml (local cache; gitignored)",
        "note: .aristo/index.toml already exists — leaving as-is.",
        serialize_initial_index,
        &mut any_change,
    )?;

    // 3b. Gitignore the runtime/per-user `.aristo/` artifacts (the regenerable
    //     index cache, review sessions, in-flight queues, nudge state,
    //     critiques, and the orphan-proof archive). Durable state (proofs, doc,
    //     specs, feedback, expectations, canon-matches) stays tracked.
    ensure_gitignored(&cwd, &mut any_change)?;

    // 4. .aristo/specs/
    create_or_note_dir(&aristo_dir.join("specs"), ".aristo/specs/", &mut any_change)?;

    // 5. .aristo/doc/
    create_or_note_dir(&aristo_dir.join("doc"), ".aristo/doc/", &mut any_change)?;

    // 6. .git/hooks/pre-commit — DEPRECATED and OPT-IN only (`--hook`). Never
    //    auto-installed: the index is a gitignored cache, so CI
    //    (`aristo verify --audit`) is the enforcement point, not a local hook.
    if hook {
        eprintln!(
            "note: the pre-commit hook is deprecated. CI (`aristo verify --audit`) is the \
             enforcement point; the hook only runs `aristo doc` + lint locally."
        );
        let hook_dir = cwd.join(".git").join("hooks");
        if hook_dir.is_dir() {
            let hook_path = hook_dir.join("pre-commit");
            if hook_path.exists() {
                println!("note: pre-commit hook already installed.");
            } else {
                fs::write(&hook_path, PRE_COMMIT_HOOK)?;
                make_executable(&hook_path)?;
                println!("ok: installed pre-commit hook (.git/hooks/pre-commit)");
                any_change = true;
            }
        } else {
            println!("note: --hook given, but no .git/hooks/ directory here; skipping.");
        }
    }

    // 7. CI workflows — opt-in. `--ci` writes the lite PR gate; `--ci-verify`
    //    additionally writes the nightly/manual verify workflow (and implies
    //    the lite gate). Default `init` writes nothing under .github/.
    let workflows_dir = cwd.join(".github").join("workflows");
    if ci || ci_verify {
        create_or_note_file_custom_msg(
            &workflows_dir.join("aristo.yml"),
            "ok: wrote .github/workflows/aristo.yml (lite PR gate; edit freely)",
            "note: .github/workflows/aristo.yml already exists.",
            || Ok(GH_WORKFLOW_STARTER.to_string()),
            &mut any_change,
        )?;
    }
    if ci_verify {
        create_or_note_file_custom_msg(
            &workflows_dir.join("aristo-verify.yml"),
            "ok: wrote .github/workflows/aristo-verify.yml (heavy verify; needs ARETTA_TOKEN secret; edit freely)",
            "note: .github/workflows/aristo-verify.yml already exists.",
            || Ok(GH_VERIFY_WORKFLOW.to_string()),
            &mut any_change,
        )?;
        print_verify_token_help(&cwd);
    }

    if !any_change {
        println!("ok: nothing to do.");
    }

    Ok(())
}

/// Runtime, per-user, and regenerable `.aristo/` paths that must NOT be
/// committed. Durable state (`.aristo/proofs/*.proof`, `.aristo/doc/`,
/// `.aristo/specs/`, `.aristo/feedback/`, `.aristo/expectations.toml`,
/// `.aristo/canon-matches.toml`, `aristo.toml`) is deliberately absent here so
/// it stays tracked and propagates to CI and fresh clones.
const ARISTO_GITIGNORE_ENTRIES: &[&str] = &[
    ".aristo/index.toml",
    ".aristo/sessions/",
    ".aristo/nudge-state.toml",
    ".aristo/verify-queue/",
    ".aristo/critique-queue/",
    ".aristo/critiques/",
    ".aristo/proofs/*.proof.bak",
    ".aristo/archive/",
];

/// Ensure every runtime/cache `.aristo/` path is ignored in the repo's
/// `.gitignore`, creating the file if absent and appending to an existing one.
/// Incremental and idempotent: only entries not already present are appended,
/// so it is safe to re-run and safe over a hand-edited `.gitignore`. Inline `#`
/// comments are intentionally not placed on entry lines (git does not treat a
/// trailing `#` as a comment).
fn ensure_gitignored(cwd: &Path, any_change: &mut bool) -> CliResult<()> {
    let path = cwd.join(".gitignore");
    let existing = fs::read_to_string(&path).unwrap_or_default();
    let present: std::collections::HashSet<&str> = existing.lines().map(str::trim).collect();
    let missing: Vec<&str> = ARISTO_GITIGNORE_ENTRIES
        .iter()
        .copied()
        .filter(|entry| !present.contains(entry))
        .collect();
    if missing.is_empty() {
        return Ok(());
    }
    let mut out = existing;
    if !out.is_empty() && !out.ends_with('\n') {
        out.push('\n');
    }
    out.push_str(
        "\n# Aristo runtime, cache, and per-user state: never commit these.\n\
         # Durable state (.aristo/proofs/*.proof, .aristo/doc/, .aristo/specs/,\n\
         # .aristo/feedback/, .aristo/expectations.toml, .aristo/canon-matches.toml,\n\
         # aristo.toml) is committed and intentionally not listed here.\n",
    );
    for entry in &missing {
        out.push_str(entry);
        out.push('\n');
    }
    fs::write(&path, out).map_err(crate::CliError::Io)?;
    for entry in &missing {
        println!("ok: added `{entry}` to .gitignore");
    }
    *any_change = true;
    Ok(())
}

fn create_or_note_file<F>(
    path: &Path,
    label: &str,
    content_fn: F,
    any_change: &mut bool,
) -> CliResult<()>
where
    F: FnOnce() -> CliResult<String>,
{
    create_or_note_file_custom_msg(
        path,
        &format!("ok: created {label}"),
        &format!("note: {label} already exists — leaving as-is."),
        content_fn,
        any_change,
    )
}

fn create_or_note_file_custom_msg<F>(
    path: &Path,
    created_msg: &str,
    exists_msg: &str,
    content_fn: F,
    any_change: &mut bool,
) -> CliResult<()>
where
    F: FnOnce() -> CliResult<String>,
{
    if path.exists() {
        println!("{exists_msg}");
    } else {
        let content = content_fn()?;
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent)?;
        }
        fs::write(path, content)?;
        println!("{created_msg}");
        *any_change = true;
    }
    Ok(())
}

fn create_or_note_dir(path: &Path, label: &str, any_change: &mut bool) -> CliResult<()> {
    if path.exists() {
        println!("note: {label} already exists.");
    } else {
        fs::create_dir(path)?;
        println!("ok: created {label}");
        *any_change = true;
    }
    Ok(())
}

/// After `--ci-verify` writes the verify workflow, point the user at the exact
/// GitHub secrets page + how to grab their token. The workflow is inert until
/// `ARETTA_TOKEN` is set, so this guidance is the difference between "wrote a
/// file" and "actually set up".
fn print_verify_token_help(cwd: &Path) {
    println!();
    println!("Next — the verify workflow needs an ARETTA_TOKEN secret on this repo:");
    match aristo_core::auth::derive_repo_full_name(cwd) {
        Ok(repo) => println!(
            "  1. Add the secret: https://github.com/{repo}/settings/secrets/actions/new  (name: ARETTA_TOKEN)"
        ),
        Err(_) => println!(
            "  1. Add the secret under Settings -> Secrets and variables -> Actions -> New repository secret  (name: ARETTA_TOKEN)"
        ),
    }
    println!(
        "  2. Token value:    `aristo auth token` prints yours — pipe to your clipboard, e.g. `aristo auth token | pbcopy`"
    );
    println!("                     ...or `aristo auth login` to mint a new one.");
}

fn serialize_default_config() -> CliResult<String> {
    let cfg = ConfigFile::default();
    toml::to_string_pretty(&cfg).map_err(|e| {
        CliError::Io(std::io::Error::other(format!(
            "serializing aristo.toml: {e}"
        )))
    })
}

fn serialize_initial_index() -> CliResult<String> {
    let index = IndexFile {
        meta: Meta {
            schema_version: 1,
            generated_by: Some(format!("aristo init {}", env!("CARGO_PKG_VERSION"))),
            generated_at: Some(now_rfc3339()),
            source_root: None,
        },
        entries: BTreeMap::new(),
    };
    toml::to_string_pretty(&index).map_err(|e| {
        CliError::Io(std::io::Error::other(format!(
            "serializing .aristo/index.toml: {e}"
        )))
    })
}

fn now_rfc3339() -> String {
    use time::format_description::well_known::Rfc3339;
    use time::OffsetDateTime;
    OffsetDateTime::now_utc()
        .format(&Rfc3339)
        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
}

#[cfg(unix)]
fn make_executable(path: &Path) -> CliResult<()> {
    use std::os::unix::fs::PermissionsExt;
    let mut perms = fs::metadata(path)?.permissions();
    perms.set_mode(0o755);
    fs::set_permissions(path, perms)?;
    Ok(())
}

#[cfg(not(unix))]
fn make_executable(_path: &Path) -> CliResult<()> {
    // Windows + non-unix: hook execution semantics differ; skipping the
    // permission bit is harmless. Cross-platform hook portability is post-MVP.
    Ok(())
}