cliban-core 0.7.0

cliban storage + domain layer: rusqlite store + writer thread + domain contexts
Documentation
//! Append-only per-issue audit log — the entries cliban records for itself
//! when an issue moves or is archived, as opposed to the narrative an author
//! writes with `cliban issue log` (which lives in the description's
//! `## Activity Log` section and is owned by the CLI's `descmd`).
//!
//! Originally a port of `backend/lib/loom/activity_log.ex`, whose `append`
//! mirrored the table back into the description on every write. cliban does
//! not: two writers rewriting one markdown section is a clobber waiting to
//! happen, so the two sources are merged at read time by `cliban activity`
//! and `cliban issue show --section activity`.
//!
//! The section helpers below are what remains of the mirror, kept because the
//! `## Activity Log` section-boundary rules they encode (from the Elixir regex
//! `^## Activity Log[ \t]*\n(?:.*?)(?=^## |\z)`) are the contract readers
//! still rely on.

use chrono::{DateTime, Utc};
use rusqlite::{params, Connection};

use crate::error::Result;
use crate::rows;
use crate::schema::{ActivityLogEntry, Issue};
use crate::time;

const SECTION_HEADER: &str = "## Activity Log";

/// `append/4`. `extra` is a serde_json value, JSON-encoded into the row
/// exactly like `Jason.encode!(extra)`.
///
/// Unlike the Elixir original this does **not** mirror the log back into the
/// issue description. In cliban the description's `## Activity Log` section is
/// the *narrative* an author curates through `cliban issue log` (see the CLI's
/// `descmd`), while this table is the *audit trail* the tool records on its
/// own — status moves, archiving. Mirroring would have one clobber the other;
/// `cliban activity` merges the two at read time instead.
pub fn append(
    conn: &Connection,
    issue: &Issue,
    kind: &str,
    message: &str,
    extra: &serde_json::Value,
) -> Result<ActivityLogEntry> {
    let extra_str = serde_json::to_string(extra)?;
    let now_str = time::format_usec(time::now_usec());

    conn.execute(
        "INSERT INTO activity_log_entries (issue_id, ts, kind, message, extra, \
         inserted_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6)",
        params![issue.id, now_str, kind, message, extra_str, now_str],
    )?;
    let id = conn.last_insert_rowid();
    let sql = format!(
        "SELECT {} FROM activity_log_entries WHERE id = ?1",
        rows::ACTIVITY_COLS
    );
    Ok(conn.query_row(&sql, params![id], rows::activity_log_entry)?)
}

/// Every entry at or after `since`, across all issues, newest first. Backs
/// `cliban activity`.
pub fn list_since(conn: &Connection, since: DateTime<Utc>) -> Result<Vec<ActivityLogEntry>> {
    let sql = format!(
        "SELECT {} FROM activity_log_entries WHERE ts >= ?1 ORDER BY ts DESC",
        rows::ACTIVITY_COLS
    );
    let mut stmt = conn.prepare(&sql)?;
    let out = stmt
        .query_map(params![time::format_usec(since)], rows::activity_log_entry)?
        .collect::<rusqlite::Result<Vec<_>>>()?;
    Ok(out)
}

/// `list_for_issue/2` — ascending by ts, default limit 200.
pub fn list_for_issue(
    conn: &Connection,
    issue_id: i64,
    limit: i64,
) -> Result<Vec<ActivityLogEntry>> {
    let sql = format!(
        "SELECT {} FROM activity_log_entries WHERE issue_id = ?1 \
         ORDER BY ts ASC LIMIT ?2",
        rows::ACTIVITY_COLS
    );
    let mut stmt = conn.prepare(&sql)?;
    let out = stmt
        .query_map(params![issue_id, limit], rows::activity_log_entry)?
        .collect::<rusqlite::Result<Vec<_>>>()?;
    Ok(out)
}

/// `render/1` — one markdown line per entry. Format mirrors the Elixir
/// `render_line/1`: `<iso8601>  <kind padded to 8>  <msg>`, trailing
/// whitespace trimmed per line.
pub fn render(entries: &[ActivityLogEntry]) -> String {
    entries
        .iter()
        .map(render_line)
        .collect::<Vec<_>>()
        .join("\n")
}

fn render_line(e: &ActivityLogEntry) -> String {
    let kind_padded = format!("{:<8}", e.kind);
    let line = format!(
        "{}  {}  {}",
        time::format_usec(e.ts),
        kind_padded,
        e.message
    );
    line.trim_end().to_string()
}

/// `merge_activity_log_section/2` (the pure helper). Public for tests + the
/// mirror path.
pub fn merge_activity_log_section(description: Option<&str>, body: &str) -> String {
    let section = build_section(body);
    match description {
        None => section,
        Some("") => section,
        Some(desc) if has_activity_section(desc) => replace_section(desc, &section),
        Some(desc) => append_section(desc, &section),
    }
}

// ---- internals ----

fn build_section(body: &str) -> String {
    let trimmed = body.trim_end();
    if trimmed.is_empty() {
        format!("{SECTION_HEADER}\n")
    } else {
        format!("{SECTION_HEADER}\n\n{trimmed}\n")
    }
}

fn has_activity_section(description: &str) -> bool {
    find_section_start(description).is_some()
}

/// Byte offset of the start of a line that is exactly `## Activity Log`
/// (optionally followed by trailing spaces/tabs), at column 0.
fn find_section_start(description: &str) -> Option<usize> {
    let mut offset = 0usize;
    for line in description.split_inclusive('\n') {
        let trimmed_nl = line.strip_suffix('\n').unwrap_or(line);
        if is_activity_header_line(trimmed_nl) {
            return Some(offset);
        }
        offset += line.len();
    }
    None
}

/// A `## Activity Log` header line: the literal header, then only spaces/tabs.
fn is_activity_header_line(line: &str) -> bool {
    if let Some(rest) = line.strip_prefix(SECTION_HEADER) {
        rest.chars().all(|c| c == ' ' || c == '\t')
    } else {
        false
    }
}

/// Replace the existing `## Activity Log` section with `section`. The section
/// runs from its header up to (but not including) the next `## ` header at
/// column 0, or end of string. Mirrors the Elixir `replace_section/2` which
/// emits `section <> "\n"` then normalizes trailing blank lines.
fn replace_section(description: &str, section: &str) -> String {
    let start = find_section_start(description).expect("has section");
    // Find the end: the next line (after the header) that begins a new
    // `## ` header at column 0.
    let after_header = &description[start..];
    let mut end_rel = after_header.len();
    let mut scanned = 0usize;
    let mut first = true;
    for line in after_header.split_inclusive('\n') {
        if first {
            // skip the header line itself
            first = false;
            scanned += line.len();
            continue;
        }
        let content = line.strip_suffix('\n').unwrap_or(line);
        if content.starts_with("## ") {
            end_rel = scanned;
            break;
        }
        scanned += line.len();
    }
    let end = start + end_rel;

    let mut out = String::with_capacity(description.len() + section.len());
    out.push_str(&description[..start]);
    out.push_str(section);
    out.push('\n');
    out.push_str(&description[end..]);
    normalize_trailing_blank_lines(&out)
}

fn normalize_trailing_blank_lines(s: &str) -> String {
    format!("{}\n", s.trim_end())
}

fn append_section(description: &str, section: &str) -> String {
    let base = description.trim_end();
    format!("{base}\n\n{section}")
}

#[cfg(test)]
mod tests {
    use super::*;

    // A line equal to the section header (ignoring trailing spaces/tabs).
    fn has_header_line(s: &str) -> bool {
        s.lines().any(is_activity_header_line)
    }

    #[test]
    fn none_description_produces_clean_section() {
        let out = merge_activity_log_section(None, "log body");
        assert_eq!(out, "## Activity Log\n\nlog body\n");
    }

    #[test]
    fn empty_description_produces_clean_section() {
        // Empty `Some("")` behaves like `None`.
        let out = merge_activity_log_section(Some(""), "log body");
        assert_eq!(out, "## Activity Log\n\nlog body\n");
    }

    #[test]
    fn empty_body_produces_header_only_section() {
        let out = merge_activity_log_section(None, "");
        assert_eq!(out, "## Activity Log\n");
        assert!(has_header_line(&out));
    }

    #[test]
    fn appends_section_when_none_exists() {
        let desc = "# Title\n\nSome prose.";
        let out = merge_activity_log_section(Some(desc), "log body");

        // Original content is preserved, and the section is appended after it.
        assert!(out.starts_with("# Title\n\nSome prose."));
        assert!(has_header_line(&out));
        let header_at = out.find(SECTION_HEADER).expect("header present");
        let prose_at = out.find("Some prose.").expect("prose present");
        assert!(
            prose_at < header_at,
            "section appended after existing content"
        );
        assert!(out.contains("log body"));
    }

    #[test]
    fn replaces_existing_section_preserving_later_section() {
        let desc = "# Title\n\nSome prose.\n\n## Activity Log\n\nold line\n\n## Other\n\nkept.";
        let out = merge_activity_log_section(desc.into(), "new body");

        // Exactly one activity header, the old body is gone, the new is in.
        assert_eq!(
            out.lines().filter(|l| is_activity_header_line(l)).count(),
            1
        );
        assert!(!out.contains("old line"), "old activity body replaced");
        assert!(out.contains("new body"), "new activity body present");

        // Both the leading prose and the trailing `## Other` section survive.
        assert!(out.contains("Some prose."));
        assert!(out.contains("## Other"));
        assert!(out.contains("kept."));

        // Structural order: prose, activity log, then the other section.
        let prose = out.find("Some prose.").unwrap();
        let activity = out.find(SECTION_HEADER).unwrap();
        let other = out.find("## Other").unwrap();
        assert!(prose < activity && activity < other);
    }
}