aristo-cli 0.2.0

Aristo CLI binary (the `aristo` command).
Documentation
//! `aristo init` — bootstrap a project for Aristo.
//!
//! Creates the four state files (`aristo.toml`, `.aristo/index.toml`,
//! `.aristo/specs/`, `.aristo/doc/`) and installs the pre-commit hook
//! (when `.git/hooks/` exists). CI workflows are opt-in: `--ci` writes the
//! lite PR gate, `--ci-verify` also writes the nightly verify workflow.
//!
//! Per `docs/diagrams/02-state-map.mmd` `w_init`, this is the only writer
//! of the index file's initial empty state — every read command can
//! safely assume `.aristo/index.toml` exists once `aristo init` has run.

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 shipped pre-commit hook (slice 21).
///
/// 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.
///
/// `aristo` must be on `PATH` for the hook to execute — `cargo install
/// aristo-cli` satisfies this for end users. The hook echoes each command
/// to stderr before running so commit-time output makes the gate
/// visible in the user's terminal.
///
/// The `-e` shell option propagates any non-zero exit from `stamp`,
/// `doc`, or `lint --check` as the hook's exit code, which aborts the
/// commit.
const PRE_COMMIT_HOOK: &str = "\
#!/usr/bin/env bash
# Aristo pre-commit hook. Installed by `aristo init`.
# Refreshes .aristo/index.toml + .aristo/doc/<id>.md files, then runs
# lint --check on the index. Non-zero exit aborts the commit.
set -e

echo \"-> aristo stamp\" >&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 checks — stamp/lint/doc --check — via the
# shared aristo-action. No token needed; runs entirely on the runner.
#
# 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: stamp, 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 .aristo/index.toml. 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) -> 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 — meta-only header, zero entries.
    create_or_note_file_custom_msg(
        &aristo_dir.join("index.toml"),
        "ok: created .aristo/index.toml (empty; 0 annotations)",
        "note: .aristo/index.toml already exists — leaving as-is.",
        serialize_initial_index,
        &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 — only when this is a git repo. We don't
    //    create .git/ ourselves; users who don't use git get a clean
    //    skip with no diagnostic noise.
    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;
        }
    }

    // 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(())
}

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(())
}