Skip to main content

lean_ctx_patterns/
jj.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Jujutsu (`jj`) output compression.
4//!
5//! `jj log` renders a two-line-per-commit graph (header with author/date, then
6//! the description). We collapse each commit to `change_id commit_id desc` and
7//! drop author/timestamp + graph art. `jj status`/`diff` keep the file-change
8//! lines and the working-copy/parent summary.
9
10use crate::ansi::strip_ansi;
11
12const GRAPH: &str = "@○◉●×◇~│├╮╭╯╰┐└┌┘ \t";
13
14pub fn compress(cmd: &str, output: &str) -> Option<String> {
15    let trimmed = output.trim();
16    if trimmed.is_empty() {
17        return Some("jj: ok".to_string());
18    }
19    if cmd.contains("log") {
20        return Some(compress_log(trimmed));
21    }
22    if cmd.contains("status") || cmd.contains(" st") || cmd.contains("diff") {
23        return compress_status(trimmed);
24    }
25    Some(fallback(trimmed))
26}
27
28fn compress_log(output: &str) -> String {
29    let lines: Vec<&str> = output.lines().collect();
30    let mut out: Vec<String> = Vec::new();
31    let mut i = 0;
32    while i < lines.len() {
33        if let Some((cid, commit)) = parse_header(lines[i]) {
34            let desc = lines
35                .get(i + 1)
36                .map(|l| strip_graph(l))
37                .filter(|d| !d.is_empty() && *d != "(no description set)")
38                .unwrap_or("(no description set)");
39            out.push(format!("{cid} {commit} {desc}").trim().to_string());
40            i += 2;
41        } else {
42            i += 1;
43        }
44    }
45    if out.is_empty() {
46        return fallback(output);
47    }
48    out.join("\n")
49}
50
51fn compress_status(output: &str) -> Option<String> {
52    let mut kept: Vec<String> = Vec::new();
53    for raw in output.lines() {
54        let line = strip_ansi(raw);
55        let line = line.trim_end();
56        let t = line.trim();
57        if t.is_empty() {
58            continue;
59        }
60        let is_file = matches!(t.chars().next(), Some('M' | 'A' | 'D' | 'R' | 'C'))
61            && t.chars().nth(1) == Some(' ');
62        // Keep working-copy/parent summary lines but drop the
63        // "Working copy changes:" section header.
64        let is_summary =
65            (t.starts_with("Working copy") || t.starts_with("Parent commit")) && t.contains(": ");
66        if is_file || is_summary {
67            kept.push(t.to_string());
68        }
69    }
70    if kept.is_empty() {
71        return None;
72    }
73    Some(kept.join("\n"))
74}
75
76fn parse_header(line: &str) -> Option<(String, String)> {
77    let body = strip_graph(line);
78    if !has_date(body) {
79        return None;
80    }
81    let tokens: Vec<&str> = body.split_whitespace().collect();
82    let cid = tokens.first()?;
83    let commit = tokens.iter().rev().find(|t| is_hex8(t))?;
84    Some((cid.to_string(), commit.to_string()))
85}
86
87fn strip_graph(line: &str) -> &str {
88    line.trim_start_matches(|c| GRAPH.contains(c)).trim_end()
89}
90
91fn has_date(s: &str) -> bool {
92    s.split_whitespace().any(|t| {
93        let p: Vec<&str> = t.split('-').collect();
94        p.len() == 3 && p[0].len() == 4 && p.iter().all(|x| x.chars().all(|c| c.is_ascii_digit()))
95    })
96}
97
98fn is_hex8(t: &str) -> bool {
99    t.len() >= 7 && t.len() <= 12 && t.chars().all(|c| c.is_ascii_hexdigit())
100}
101
102fn fallback(text: &str) -> String {
103    let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
104    let n = lines.len().min(10);
105    let mut s = lines[..n].join("\n");
106    if lines.len() > n {
107        s.push_str(&format!("\n... (+{} lines)", lines.len() - n));
108    }
109    s
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    const LOG: &str = "@  qpvuntsm user@host.com 2024-01-01 12:00:00 1234abcd\n│  add feature x\n○  zzzzmmmm user@host.com 2024-01-01 11:00:00 main 5678efab\n│  initial commit\n~\n";
117
118    #[test]
119    fn log_collapses_commits() {
120        let r = compress("jj log", LOG).unwrap();
121        assert!(r.contains("qpvuntsm 1234abcd add feature x"), "{r}");
122        assert!(r.contains("zzzzmmmm 5678efab initial commit"), "{r}");
123        assert!(!r.contains("user@host"), "drops author: {r}");
124        assert!(!r.contains("12:00:00"), "drops time: {r}");
125    }
126
127    #[test]
128    fn status_keeps_file_changes() {
129        let st = "Working copy changes:\nM src/main.rs\nA src/new.rs\nWorking copy : qpvuntsm 1234abcd (no description set)\nParent commit: zzzzmmmm 5678efab main | initial";
130        let r = compress("jj status", st).unwrap();
131        assert!(r.contains("M src/main.rs"), "{r}");
132        assert!(r.contains("Parent commit"), "{r}");
133        assert!(
134            !r.contains("Working copy changes:"),
135            "drops header noise: {r}"
136        );
137    }
138
139    #[test]
140    fn empty_is_ok() {
141        assert_eq!(compress("jj log", "").unwrap(), "jj: ok");
142    }
143}