foam 0.1.0

an issue tracker and agent memory that lives on a git ref
use std::fmt::Write;

use jiff::Timestamp;

use crate::cmd::age;
use crate::git::{Distance, Git};
use crate::graph;
use crate::model::Status;
use crate::store::Db;

/// Memories older than this many commits get an age note.
const STALE_AFTER: u64 = 50;

const CONTRACT: &str = "\
foam tracks this repository's issues and memories on a git ref; nothing is \
checked out and nothing here is a file to edit. Start with `foam ready` and \
`foam claim <id>` before working on an issue. Record progress with \
`foam note <id> <text>`, finish with `foam close <id> --reason <why>`, and \
`foam unclaim <id>` anything you stop working on. Store facts the next \
session needs with `foam remember <slug> <text>`; check `foam memories` \
before relying on one that is marked as old or from another branch.";

const CHEAT_SHEET: &str = "\
foam ready [--limit N]          issues that can be worked now
foam show <id>                  one issue in full, with notes and blockers
foam create <title> [--type T] [-p 0-4] [--blocked-by ID] [--parent ID]
foam claim <id> | unclaim <id> | heartbeat <id>
foam note <id> <text>           append a note
foam close <id> --reason <why>  | foam reopen <id>
foam dep add <id> <blocker>     make <id> wait on <blocker>
foam blocked                    what is waiting, and on what
foam search <query>             titles, bodies and notes
foam remember <slug> <text> | memories | recall <slug> | forget <slug>
foam update <id> [--title ..] [--priority N] [--defer-until DATE]
Add --json to any command for machine-readable output.";

/// Render the session-start context for `actor`.
pub fn render(db: &Db, git: &Git, actor: &str, limit: usize) -> String {
    let now = Timestamp::now();
    let mut out = String::new();
    out.push_str("# foam\n\n");
    out.push_str(CONTRACT);
    out.push_str("\n\n## Commands\n\n");
    out.push_str(CHEAT_SHEET);
    out.push('\n');

    let count = |s: Status| db.issues.values().filter(|i| i.status == s).count();
    let stamp = git.head_stamp().ok();
    let (branch, commit) = stamp
        .as_ref()
        .map(|s| (s.branch.as_str(), s.commit.as_str()))
        .unwrap_or(("?", "?"));
    let _ = write!(
        out,
        "\n## Status\n\n{} open, {} in progress, {} deferred, {} closed; on {branch} at {commit}\n",
        count(Status::Open),
        count(Status::InProgress),
        count(Status::Deferred),
        count(Status::Closed),
    );
    let expired = db
        .issues
        .values()
        .filter(|i| i.status == Status::InProgress && i.lease_expires.is_none_or(|t| t <= now))
        .count();
    if expired > 0 {
        let _ = writeln!(
            out,
            "{expired} in-progress issue(s) have an expired lease; `foam reclaim` reopens them"
        );
    }

    let mine: Vec<_> = db
        .issues
        .values()
        .filter(|i| i.status == Status::InProgress && i.assignee.as_deref() == Some(actor))
        .collect();
    if !mine.is_empty() {
        let _ = write!(out, "\n## In progress for {actor}\n\n");
        for i in mine {
            let lease = match i.lease_expires {
                Some(t) if t > now => {
                    let mins = t.duration_since(now).as_mins();
                    format!("lease {mins} min left")
                }
                _ => "lease expired".to_string(),
            };
            let _ = writeln!(out, "{}  P{}  {}  ({lease})", i.id, i.priority, i.title);
        }
    }

    let ready = graph::ready(db, now);
    let _ = write!(out, "\n## Ready ({} total)\n\n", ready.len());
    if ready.is_empty() {
        out.push_str("nothing is ready\n");
    }
    for i in ready.iter().take(limit) {
        let _ = writeln!(out, "{}  P{}  {}  {}", i.id, i.priority, i.kind, i.title);
    }

    if !db.memories.is_empty() {
        out.push_str("\n## Memories\n\n");
        for m in db.memories.values() {
            let distance = git.distance(&m.stamp.commit);
            let note = match distance {
                Distance::Behind(n) if n < STALE_AFTER => String::new(),
                _ => format!("  [{}]", age(&m.stamp, distance)),
            };
            let _ = writeln!(out, "{}: {}{note}", m.slug, m.text);
        }
    }
    out
}

/// Wrap `context` the way Claude Code reads a SessionStart hook's output.
pub fn hook_json(context: &str) -> String {
    serde_json::json!({
        "hookSpecificOutput": {
            "hookEventName": "SessionStart",
            "additionalContext": context,
        }
    })
    .to_string()
}