yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
use super::line::clip_arg;
use super::*;
use tempfile::tempdir;

/// A representative untruncated entry.
fn sample() -> OpEntry {
    OpEntry {
        ts: "2026-07-17T12:00:00Z".into(),
        argv: vec!["bl".into(), "close".into(), "bl-4db6".into()],
        cwd: "/home/mark/dev/brazen".into(),
        exit: 0,
        stdout: "ok".into(),
        stderr: String::new(),
    }
}

/// Parse a built line back to a JSON object for field assertions.
fn parsed(line: &[u8]) -> serde_json::Map<String, Value> {
    assert_eq!(*line.last().unwrap(), b'\n', "line must end in newline");
    serde_json::from_slice::<Value>(&line[..line.len() - 1])
        .unwrap()
        .as_object()
        .unwrap()
        .clone()
}

#[test]
fn build_line_serializes_all_fields_untruncated() {
    let line = build_line(&sample());
    assert!(line.len() <= CAP);
    let obj = parsed(&line);
    assert_eq!(obj["ts"], Value::from("2026-07-17T12:00:00Z"));
    assert_eq!(obj["argv"], Value::from(vec!["bl", "close", "bl-4db6"]));
    assert_eq!(obj["cwd"], Value::from("/home/mark/dev/brazen"));
    assert_eq!(obj["exit"], Value::from(0));
    assert_eq!(obj["stdout"], Value::from("ok"));
    assert_eq!(obj["stderr"], Value::from(""));
    assert!(!obj.contains_key("truncated"));
}

#[test]
fn build_line_boundary_exactly_cap_then_one_over() {
    let base = build_line(&OpEntry {
        stdout: String::new(),
        ..sample()
    })
    .len();

    // Exactly CAP: 'a' is unescaped, so each byte of stdout adds one byte.
    let at_cap = build_line(&OpEntry {
        stdout: "a".repeat(CAP - base),
        ..sample()
    });
    assert_eq!(at_cap.len(), CAP);
    assert!(!parsed(&at_cap).contains_key("truncated"));

    // One over: must truncate stdout and mark the line.
    let over = build_line(&OpEntry {
        stdout: "a".repeat(CAP - base + 1),
        ..sample()
    });
    assert!(over.len() <= CAP);
    let obj = parsed(&over);
    assert_eq!(obj["truncated"], Value::Bool(true));
    let kept = obj["stdout"].as_str().unwrap();
    assert!(kept.len() < CAP - base + 1);
    assert!(kept.bytes().all(|b| b == b'a'));
}

#[test]
fn build_line_truncates_huge_stdout_keeping_stderr() {
    let line = build_line(&OpEntry {
        stdout: "a".repeat(100_000),
        stderr: "keep-me".into(),
        ..sample()
    });
    assert!(line.len() <= CAP);
    let obj = parsed(&line);
    assert_eq!(obj["truncated"], Value::Bool(true));
    assert_eq!(obj["stderr"], Value::from("keep-me"));
    let kept = obj["stdout"].as_str().unwrap();
    assert!(!kept.is_empty() && kept.bytes().all(|b| b == b'a'));
}

#[test]
fn build_line_sacrifices_stdout_before_stderr() {
    // stderr so large that even an empty stdout overflows: stdout must go to
    // zero first, then stderr shrinks (§4.2 order).
    let line = build_line(&OpEntry {
        stdout: "dropped-entirely".into(),
        stderr: "b".repeat(100_000),
        ..sample()
    });
    assert!(line.len() <= CAP);
    let obj = parsed(&line);
    assert_eq!(obj["truncated"], Value::Bool(true));
    assert_eq!(obj["stdout"], Value::from(""));
    let kept = obj["stderr"].as_str().unwrap();
    assert!(!kept.is_empty() && kept.bytes().all(|b| b == b'b'));
}

#[test]
fn build_line_fixed_fields_exceeding_cap_zero_the_outputs() {
    // A pathological argv alone blows the cap: outputs go to zero and the
    // line is allowed to exceed — structurally unavoidable.
    let line = build_line(&OpEntry {
        argv: vec!["x".repeat(5_000)],
        stdout: "gone".into(),
        stderr: "also-gone".into(),
        ..sample()
    });
    assert!(line.len() > CAP);
    let obj = parsed(&line);
    assert_eq!(obj["truncated"], Value::Bool(true));
    assert_eq!(obj["stdout"], Value::from(""));
    assert_eq!(obj["stderr"], Value::from(""));
    assert_eq!(obj["argv"].as_array().unwrap().len(), 1);
}

#[test]
fn build_line_snaps_head_to_utf8_boundary() {
    // 'é' is two bytes; truncation lands the byte budget mid-char, so the head
    // must snap back to a boundary and stay valid UTF-8.
    let line = build_line(&OpEntry {
        stdout: "é".repeat(50_000),
        stderr: "z".into(),
        ..sample()
    });
    assert!(line.len() <= CAP);
    let obj = parsed(&line);
    let kept = obj["stdout"].as_str().unwrap();
    assert!(!kept.is_empty());
    assert!(kept.chars().all(|c| c == 'é'));
}

#[test]
fn append_then_tail_round_trips_in_order() {
    let dir = tempdir().unwrap();
    let root = dir.path();
    let first = sample();
    let second = OpEntry {
        argv: vec!["lernie".into(), "scan".into()],
        stdout: "summary".into(),
        ..sample()
    };
    append(root, &first).unwrap();
    append(root, &second).unwrap();

    assert_eq!(tail(root, 10), vec![first, second.clone()]);
    // Cap trims the head, keeping newest-last.
    assert_eq!(tail(root, 1), vec![second]);
}

#[test]
fn append_creates_missing_state_dirs() {
    let dir = tempdir().unwrap();
    let root = dir.path().join("nested").join("state");
    append(&root, &sample()).unwrap();
    assert_eq!(tail(&root, 5), vec![sample()]);
}

#[test]
fn tail_of_missing_file_is_empty() {
    let dir = tempdir().unwrap();
    assert!(tail(&dir.path().join("absent"), 5).is_empty());
}

#[test]
fn tail_skips_corrupt_and_non_object_lines() {
    let dir = tempdir().unwrap();
    let root = dir.path();
    let path = root.join(FILENAME);
    let good = build_line(&sample());
    let mut bytes = good.clone();
    bytes.extend_from_slice(b"{not valid json\n"); // corrupt -> skipped
    bytes.extend_from_slice(b"42\n"); // valid JSON, not an object -> skipped
    bytes.extend_from_slice(b"\n"); // blank -> skipped
    bytes.extend_from_slice(&[0xff, 0xfe, b'\n']); // invalid UTF-8 -> skipped
    bytes.extend_from_slice(&good); // a second good line
    fs::write(&path, &bytes).unwrap();

    let got = tail(root, 10);
    assert_eq!(got, vec![sample(), sample()]);
}

#[test]
fn parse_line_defaults_absent_and_mistyped_fields() {
    let dir = tempdir().unwrap();
    let root = dir.path();
    let path = root.join(FILENAME);
    // Empty object -> all defaults; mixed argv drops non-strings; wrong-typed
    // exit falls back to 0.
    fs::write(&path, b"{}\n{\"argv\":[\"a\",5,\"b\"],\"exit\":\"nope\"}\n").unwrap();

    let got = tail(root, 10);
    assert_eq!(
        got,
        vec![
            OpEntry {
                ts: String::new(),
                argv: vec![],
                cwd: String::new(),
                exit: 0,
                stdout: String::new(),
                stderr: String::new(),
            },
            OpEntry {
                ts: String::new(),
                argv: vec!["a".into(), "b".into()],
                cwd: String::new(),
                exit: 0,
                stdout: String::new(),
                stderr: String::new(),
            },
        ]
    );
}

#[test]
fn synthetic_failure_encodes_the_intended_argv_and_stderr() {
    let e = OpEntry::synthetic_failure(
        "TS".into(),
        vec!["lernie".into(), "prompt".into()],
        "/proj".into(),
        "No such file or directory".into(),
    );
    assert_eq!(e.exit, SYNTHETIC_EXIT);
    assert_eq!(e.argv, vec!["lernie".to_string(), "prompt".to_string()]);
    assert_eq!(e.cwd, "/proj");
    assert!(e.stdout.is_empty());
    assert_eq!(e.stderr, "No such file or directory");
}

#[test]
fn step_failure_encodes_the_yog_step_argv() {
    let e = OpEntry::step_failure("TS".into(), "mint", String::new(), "pool exhausted".into());
    assert_eq!(e.argv, vec![YOG_STEP.to_string(), "mint".to_string()]);
    assert_eq!(e.exit, SYNTHETIC_EXIT);
    assert_eq!(e.stderr, "pool exhausted");
}

#[test]
fn clip_arg_returns_short_verbatim_and_marks_a_long_one() {
    // Verbatim branch: within budget → unchanged, no marker.
    assert_eq!(clip_arg("goal", 16), "goal");
    // Truncate branch: over budget → head + an explicit elision marker.
    let clipped = clip_arg(&"x".repeat(100), 10);
    assert!(clipped.starts_with(&"x".repeat(10)));
    assert!(
        clipped.contains("[+90 bytes elided]"),
        "the marker names the dropped byte count"
    );
}

#[test]
fn clip_goal_leaves_a_fitting_entry_untouched() {
    // A small entry already serializes ≤ CAP → returned verbatim, no clip.
    let e = sample();
    assert_eq!(clip_goal(&e), e);
}

#[test]
fn clip_goal_on_empty_argv_is_a_no_op() {
    // Nothing to clip: split_last is None, the entry rides back unchanged.
    let e = OpEntry {
        argv: vec![],
        ..sample()
    };
    assert_eq!(clip_goal(&e), e);
}

#[test]
fn clip_goal_holds_cap_after_json_escape() {
    // Each byte JSON-escapes to `\u00XX` (6 bytes): a 9000-byte goal would
    // serialize to ~54 KB unclipped. clip_goal trims the tail argv element against
    // the *serialized* length, so the line lands ≤ CAP — not a raw-byte proxy.
    let e = OpEntry {
        argv: vec![
            "lernie".into(),
            "prompt".into(),
            "/ws".into(),
            "\u{1}".repeat(9000),
        ],
        ..sample()
    };
    assert!(build_line(&e).len() > CAP, "the raw goal overflows the cap");
    let clipped = clip_goal(&e);
    assert!(
        build_line(&clipped).len() <= CAP,
        "serialized line ≤ CAP post-escape"
    );
    assert!(
        clipped.argv.last().unwrap().contains("bytes elided"),
        "the clipped goal carries the elision marker"
    );
    // Only the tail element clips; the fixed argv prefix is preserved.
    assert_eq!(&clipped.argv[..3], &e.argv[..3]);
}