aristo-cli 0.1.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/`), installs the pre-commit hook
//! (when `.git/hooks/` exists), and writes a starter GitHub Actions
//! 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` 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 CI gates (starter; edit freely).
# Generated by `aristo init`.
#
# Gate order: stamp first (everything downstream reads the index — fail
# fast on staleness), lint second (cheapest static check), doc third
# (purely derived from index). Each `--check` mode is non-mutating and
# exits non-zero on drift.
#
# NOTE: the `verify --check --strict` step is intentionally omitted while
# the `verify=\"test\"` and `verify=\"full\"` pipelines are still under
# design. Once `verify=\"test\"` ships, add this step BEFORE `doc --check`:
#     - name: verify --check --strict (no stale/orphan/forged)
#       run: aristo verify --check --strict
#
# The badge step generates a fresh SVG on every push and uploads it as
# a workflow artifact. The artifact URL is per-build (GH-Actions
# specific); for a stable README badge, either commit `docs/badge.svg`
# directly (regenerated locally via the pre-commit hook) or set up a
# gh-pages publish step downstream of this workflow.
name: aristo
on: [push, pull_request]
jobs:
  aristo:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: cargo install aristo --locked
      - name: stamp --check (index in sync with source)
        run: aristo stamp --check
      - name: lint --check --strict (no warn/error findings)
        run: aristo lint --check --strict
      - name: doc --check (doc artifacts in sync with index)
        run: aristo doc --check
      - name: status (per-pipeline rate + tier — informational)
        run: aristo status
      - name: badge (regenerate SVG)
        run: aristo badge --out=docs/badge.svg
      - name: upload badge artifact
        uses: actions/upload-artifact@v4
        with:
          name: aristo-badge
          path: docs/badge.svg
";

#[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) -> 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. .github/workflows/aristo.yml — starter workflow.
    let workflows_dir = cwd.join(".github").join("workflows");
    let workflow_path = workflows_dir.join("aristo.yml");
    if workflow_path.exists() {
        println!("note: .github/workflows/aristo.yml already exists.");
    } else {
        fs::create_dir_all(&workflows_dir)?;
        fs::write(&workflow_path, GH_WORKFLOW_STARTER)?;
        println!("ok: wrote .github/workflows/aristo.yml (starter; edit freely)");
        any_change = true;
    }

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

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