jjf 0.2.0

Fuzzy revision picker for jujutsu (jj). Wraps any jj subcommand with an interactive picker over jj log, with a live jj show preview pane.
use std::process::{Command, ExitStatus, Stdio};

use anyhow::{Context, Result};

const SEP: u8 = 0x1f;

// Custom oneline template emitting field-separated values. We run jj with
// its native `--graph` so the merge connectors (`├─┬─╮`, `╰─╯`) render
// exactly as they do in `jj log` — the picker treats those connector-only
// rows as decoration and keeps the cursor on commit rows.
//
// The template MUST emit a single line per commit (no embedded `\n`,
// `ui.log-word-wrap=false`), or jj's graph drawer would inject continuation
// rows into our one-line invariant.
//
// Fields (\x1f-separated, leading `\x1f` separates jj's graph chrome from
// our fields):
//   0  graph chrome (everything jj prepended before the first \x1f)
//   1  change_id.short()                 — full 12-char change id
//   2  change_id.shortest().prefix()     — shortest unique prefix
//   3  commit_id.short()                 — full 12-char commit id
//   4  commit_id.shortest().prefix()     — shortest unique prefix
//   5  payload — change-id-prefix-highlighted, bookmarks, conflict/empty,
//                description.first_line()
const TEMPLATE: &str = concat!(
    "\"\\x1f\" ++ ",
    "change_id.short() ++ \"\\x1f\" ++ ",
    "change_id.shortest().prefix() ++ \"\\x1f\" ++ ",
    "commit_id.short() ++ \"\\x1f\" ++ ",
    "commit_id.shortest().prefix() ++ \"\\x1f\" ++ ",
    "(separate(\" \", \
        format_short_change_id(self.change_id()), \
        bookmarks, \
        if(conflict, label(\"conflict\", \"conflict\")), \
        if(empty, label(\"empty\", \"(empty)\")), \
        description.first_line() \
    ))",
);

/// Either a commit row (selectable) or a connector row (decoration only —
/// `├─┬─╮`, `│ │`, `~`, …). Connectors have empty `change_id_short`.
pub struct Row {
    pub change_id_short: String,
    pub change_id_prefix: String,
    pub commit_id_short: String,
    pub commit_id_prefix: String,
    pub plain: String,
    pub styled: Vec<u8>,
}

impl Row {
    pub fn is_connector(&self) -> bool {
        self.change_id_short.is_empty()
    }
}

pub fn capture_log() -> Result<Vec<Row>> {
    let output = Command::new("jj")
        .args([
            "--ignore-working-copy",
            // Force off any user `ui.log-word-wrap = true` — wrapping would
            // split a description into a continuation row that looks
            // syntactically identical to a graph connector row, breaking the
            // one-row-per-commit invariant the picker relies on.
            "--config",
            "ui.log-word-wrap=false",
            "log",
            "--color=always",
            "-r",
            "all()",
            "--limit",
            "1000",
            "-T",
            TEMPLATE,
        ])
        .stderr(Stdio::inherit())
        .output()
        .context("failed to spawn jj — is it installed and on PATH?")?;

    if !output.status.success() {
        std::process::exit(output.status.code().unwrap_or(1));
    }

    let mut rows = Vec::new();
    for raw in output.stdout.split(|&b| b == b'\n') {
        if raw.is_empty() {
            continue;
        }
        rows.push(parse_row(raw));
    }
    Ok(rows)
}

/// Parse one line of `jj log --graph` output. A line with our 5 `\x1f`
/// separators is a commit row; anything else is a connector / `~` /
/// continuation row that we keep as decoration but render unselectable.
fn parse_row(bytes: &[u8]) -> Row {
    let mut parts: Vec<&[u8]> = Vec::with_capacity(6);
    let mut start = 0;
    for (i, &b) in bytes.iter().enumerate() {
        if b == SEP {
            parts.push(&bytes[start..i]);
            start = i + 1;
            if parts.len() == 5 {
                break;
            }
        }
    }
    if parts.len() < 5 {
        // Connector / `~` / pure-graph row — keep as decoration only.
        let styled = bytes.to_vec();
        let plain = strip_ansi(&styled);
        return Row {
            change_id_short: String::new(),
            change_id_prefix: String::new(),
            commit_id_short: String::new(),
            commit_id_prefix: String::new(),
            plain,
            styled,
        };
    }
    parts.push(&bytes[start..]);

    let graph = parts[0];
    let change_id_short = strip_ansi(parts[1]);
    let change_id_prefix = strip_ansi(parts[2]);
    let commit_id_short = strip_ansi(parts[3]);
    let commit_id_prefix = strip_ansi(parts[4]);
    let payload = parts[5];

    let mut styled = Vec::with_capacity(graph.len() + payload.len());
    styled.extend_from_slice(graph);
    styled.extend_from_slice(payload);
    let plain = strip_ansi(&styled);

    Row {
        change_id_short,
        change_id_prefix,
        commit_id_short,
        commit_id_prefix,
        plain,
        styled,
    }
}

/// Strip CSI escape sequences (ESC `[` … letter) from a byte slice.
/// Non-CSI bytes pass through; result is decoded as UTF-8 (lossy) so the
/// graph chrome (`@`, `○`, `◆`) survives.
pub fn strip_ansi(bytes: &[u8]) -> String {
    let mut out = Vec::with_capacity(bytes.len());
    let mut i = 0;
    while i < bytes.len() {
        if bytes[i] == 0x1b && bytes.get(i + 1) == Some(&b'[') {
            i += 2;
            while i < bytes.len() && !bytes[i].is_ascii_alphabetic() {
                i += 1;
            }
            if i < bytes.len() {
                i += 1;
            }
        } else {
            out.push(bytes[i]);
            i += 1;
        }
    }
    String::from_utf8_lossy(&out).into_owned()
}

/// Description and file-summary parts of `jj show --summary`, returned
/// separately so the renderer can fit them dynamically into the available
/// vertical space.
pub struct PreviewParts {
    /// Description lines (still 4-space-indented as jj show emits them),
    /// joined by `\n`. No trailing blank.
    pub description: Vec<u8>,
    /// File list (`M path`, `A path`, …) joined by `\n`. No leading blank.
    pub files: Vec<u8>,
}

/// Run `jj show --summary` for a single revision and split the result into
/// description and file-list parts (stripping the `Commit ID:` / `Change ID:`
/// / `Author:` / `Committer:` header).
pub fn show_summary(change_id: &str) -> PreviewParts {
    let out = Command::new("jj")
        .args([
            "--ignore-working-copy",
            "show",
            "--color=always",
            "--summary",
            "-r",
            change_id,
        ])
        .stderr(Stdio::null())
        .output();
    match out {
        Ok(o) if o.status.success() => parse_show_summary(&o.stdout),
        _ => PreviewParts {
            description: b"(preview unavailable)".to_vec(),
            files: Vec::new(),
        },
    }
}

fn parse_show_summary(bytes: &[u8]) -> PreviewParts {
    // Skip the metadata header — everything up to and including the first
    // `\n\n` (blank line) is `Commit ID:` / `Change ID:` / `Author:` /
    // `Committer:` and we don't want any of it.
    let body: &[u8] = if let Some(pos) = bytes.windows(2).position(|w| w == b"\n\n") {
        &bytes[pos + 2..]
    } else {
        bytes
    };

    let lines: Vec<&[u8]> = body.split(|&b| b == b'\n').collect();

    // Description block: leading lines that are either 4-space-indented or
    // blank. Stops at the first non-indented non-blank line, which is the
    // first file entry (e.g. `M src/main.rs`).
    let mut desc_end = 0;
    for (i, line) in lines.iter().enumerate() {
        if line.is_empty() || line.starts_with(b"    ") {
            desc_end = i + 1;
        } else {
            break;
        }
    }
    // Trim trailing blank lines from the description block.
    while desc_end > 0 && lines[desc_end - 1].is_empty() {
        desc_end -= 1;
    }

    // Skip the first description line — it's the title, already shown on
    // the left-pane oneline as `description.first_line()`. Also skip any
    // blank lines immediately after it so the body starts cleanly.
    let mut desc_start = if desc_end > 0 { 1 } else { 0 };
    while desc_start < desc_end && lines[desc_start].is_empty() {
        desc_start += 1;
    }

    let mut description = Vec::new();
    for (i, line) in lines[desc_start..desc_end].iter().enumerate() {
        if i > 0 {
            description.push(b'\n');
        }
        // jj show indents description lines with 4 spaces — strip them so
        // the preview pane doesn't waste a 4-char left margin (and so our
        // wrapper has the full width to wrap into).
        let stripped: &[u8] = line.strip_prefix(b"    ").unwrap_or(line);
        description.extend_from_slice(stripped);
    }

    let mut files = Vec::new();
    let mut first = true;
    for line in lines.iter().skip(desc_end) {
        if line.is_empty() {
            continue;
        }
        if !first {
            files.push(b'\n');
        }
        files.extend_from_slice(line);
        first = false;
    }

    PreviewParts { description, files }
}

pub fn supports_revisions(subcommand: &str) -> Result<bool> {
    let output = Command::new("jj")
        .arg(subcommand)
        .arg("--help")
        .output()
        .context("failed to spawn jj for preflight")?;
    if !output.status.success() {
        // unknown subcommand or other error — let the main flow surface jj's error
        return Ok(true);
    }
    let help = String::from_utf8_lossy(&output.stdout);
    Ok(help.contains("REVSET"))
}

pub fn exec(subcommand: &str, passthrough: &[String], ids: &[String]) -> Result<ExitStatus> {
    let revset = ids.join("|");
    let status = Command::new("jj")
        .arg(subcommand)
        .args(passthrough)
        .arg("-r")
        .arg(&revset)
        .status()
        .context("failed to spawn jj")?;
    Ok(status)
}

pub fn command_line(subcommand: &str, passthrough: &[String], ids: &[String]) -> String {
    let mut parts: Vec<String> = Vec::with_capacity(4 + passthrough.len());
    parts.push("jj".into());
    parts.push(subcommand.into());
    for a in passthrough {
        parts.push(shell_quote(a));
    }
    if !ids.is_empty() {
        parts.push("-r".into());
        parts.push(shell_quote(&ids.join("|")));
    }
    parts.join(" ")
}

pub fn shell_quote(s: &str) -> String {
    if s.is_empty() {
        return "''".into();
    }
    if s.bytes()
        .all(|b| b.is_ascii_alphanumeric() || b"_-./=:@,".contains(&b))
    {
        return s.to_string();
    }
    let mut out = String::with_capacity(s.len() + 2);
    out.push('\'');
    for c in s.chars() {
        if c == '\'' {
            out.push_str(r"'\''");
        } else {
            out.push(c);
        }
    }
    out.push('\'');
    out
}