liber-cli 0.1.0

AI-agent-readable company directory CLI
//! `liber init <slug> <path>` — scaffold a fresh data directory.

use std::path::Path;

use serde_json::json;

use crate::output::{emit_no_op, emit_ok, CliError, Ctx};

pub fn run(
    ctx: Ctx,
    slug: &str,
    path: &Path,
    force: bool,
) -> Result<(), CliError> {
    if !is_valid_slug(slug) {
        return Err(CliError::validation(
            format!("invalid company slug: '{slug}'"),
            Some("use lowercase letters, digits, '-' or '_'; must start with [a-z0-9]".into()),
        ));
    }

    let already_exists = path.exists();
    if already_exists && !force {
        // Idempotency: if all entity files already exist, treat as no-op.
        let all_present = ["people", "products", "customers", "chats", "repos"]
            .iter()
            .all(|n| path.join(format!("{n}.json")).exists());
        if all_present {
            emit_no_op(
                ctx,
                &format!("{} already initialized — no changes", path.display()),
            );
            return Ok(());
        }
        // Directory exists but is incomplete — refuse without --force.
        return Err(CliError::conflict(
            format!(
                "path already exists and is not a complete liber data dir: {}",
                path.display()
            ),
            Some("re-run with --force to overwrite, or point at an empty path".into()),
        ));
    }

    std::fs::create_dir_all(path).map_err(|e| {
        CliError::data(
            format!("could not create {}: {e}", path.display()),
            Some("check parent directory permissions".into()),
        )
    })?;

    write_file(path, "people.json", &people_template(slug), force)?;
    write_file(path, "products.json", &products_template(slug), force)?;
    write_file(path, "customers.json", &customers_template(), force)?;
    write_file(path, "chats.json", &chats_template(), force)?;
    write_file(path, "repos.json", &repos_template(slug), force)?;

    let payload = json!({
        "ok": true,
        "company": slug,
        "data_dir": path.display().to_string(),
        "files": ["people.json", "products.json", "customers.json", "chats.json", "repos.json"],
        "next": [
            format!("cd {}", path.display()),
            "edit people.json / products.json / customers.json / chats.json".to_string(),
            format!("LIBER_DATA_DIR=. liber validate"),
            format!("LIBER_DATA_DIR=. liber people list"),
        ],
    });
    if ctx.json {
        emit_ok(ctx, payload);
    } else {
        emit_ok(
            ctx,
            json!({
                "message": format!(
                    "initialized liber data dir for '{slug}' at {}\nnext: edit the JSON files, then run `LIBER_DATA_DIR=. liber validate`",
                    path.display()
                )
            }),
        );
    }
    Ok(())
}

fn is_valid_slug(s: &str) -> bool {
    if s.is_empty() {
        return false;
    }
    let first = s.chars().next().unwrap();
    if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
        return false;
    }
    s.chars()
        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
}

fn write_file(dir: &Path, name: &str, value: &serde_json::Value, force: bool) -> Result<(), CliError> {
    let target = dir.join(name);
    if target.exists() && !force {
        // Idempotent: silently keep the existing file.
        return Ok(());
    }
    let bytes = serde_json::to_vec_pretty(value).expect("serialize template");
    std::fs::write(&target, bytes).map_err(|e| {
        CliError::data(
            format!("could not write {}: {e}", target.display()),
            None,
        )
    })?;
    Ok(())
}

fn people_template(_slug: &str) -> serde_json::Value {
    json!({
        "departments": {
            "Engineering": {
                "members": [
                    {
                        "name": "Ada Example",
                        "email": "ada@example.com",
                        "github": "ada-example",
                        "git_aliases": ["ada", "Ada Example"]
                    }
                ]
            }
        },
        "root_users": [],
        "updated_at": today()
    })
}

fn products_template(slug: &str) -> serde_json::Value {
    json!({
        "products": [
            {
                "slug": format!("{slug}-core"),
                "name": "Example Product",
                "description": "Main product (edit me)."
            }
        ],
        "updated_at": today()
    })
}

fn customers_template() -> serde_json::Value {
    json!({
        "customers": [
            {
                "slug": "example-customer",
                "name": "Example Customer",
                "related_products": [],
                "chats": [],
                "notes": null
            }
        ],
        "updated_at": today()
    })
}

fn chats_template() -> serde_json::Value {
    json!({
        "platform": "feishu",
        "group_chats": {},
        "updated_at": today()
    })
}

fn repos_template(slug: &str) -> serde_json::Value {
    json!({
        "repos": [
            {
                "slug": format!("{slug}-core"),
                "url": format!("https://github.com/{slug}/{slug}-core"),
                "visibility": "private",
                "description": "Main repo (edit me)."
            }
        ],
        "updated_at": today()
    })
}

fn today() -> String {
    // Avoid pulling chrono; use the system time, format YYYY-MM-DD via division.
    // SystemTime since UNIX epoch -> days -> civil date.
    use std::time::{SystemTime, UNIX_EPOCH};
    let secs = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs() as i64)
        .unwrap_or(0);
    let days = secs / 86_400;
    let (y, m, d) = civil_from_days(days);
    format!("{y:04}-{m:02}-{d:02}")
}

/// Convert days since 1970-01-01 to a (year, month, day) civil date.
/// Howard Hinnant's algorithm.
fn civil_from_days(z: i64) -> (i32, u32, u32) {
    let z = z + 719_468;
    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
    let doe = (z - era * 146_097) as u32;
    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
    let y = yoe as i64 + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1;
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
    let y = if m <= 2 { y + 1 } else { y };
    (y as i32, m, d)
}