lazyspec 0.8.0

A little TUI & CLI for project documentation.
Documentation
mod common;

use common::TestFixture;

fn setup() -> TestFixture {
    let fixture = TestFixture::new();
    fixture.write_doc(
        "docs/rfcs/RFC-001-auth.md",
        "---\ntitle: \"Auth Redesign\"\ntype: rfc\nstatus: accepted\nauthor: jkaloger\ndate: 2026-03-01\ntags: [security]\nrelated: []\n---\n\nRFC body.\n",
    );
    fixture.write_doc(
        "docs/stories/STORY-001-auth-impl.md",
        "---\ntitle: \"Auth Implementation\"\ntype: story\nstatus: draft\nauthor: jkaloger\ndate: 2026-03-02\ntags: [security]\nrelated:\n- implements: docs/rfcs/RFC-001-auth.md\n---\n\nStory body.\n",
    );
    fixture.write_doc(
        "docs/iterations/ITERATION-001-auth-sprint.md",
        "---\ntitle: \"Auth Sprint 1\"\ntype: iteration\nstatus: draft\nauthor: agent\ndate: 2026-03-03\ntags: []\nrelated:\n- implements: docs/stories/STORY-001-auth-impl.md\n---\n\nIteration body.\n",
    );
    fixture
}

#[test]
fn context_walks_full_chain() {
    let fixture = setup();
    let store = fixture.store();
    let resolved = lazyspec::cli::context::resolve_chain(&store, "ITERATION-001").unwrap();

    assert_eq!(resolved.chain.len(), 3);
    assert_eq!(resolved.chain[0].title, "Auth Redesign");
    assert_eq!(resolved.chain[1].title, "Auth Implementation");
    assert_eq!(resolved.chain[2].title, "Auth Sprint 1");
    assert_eq!(resolved.target_index, 2);
}

#[test]
fn context_standalone_document() {
    let fixture = setup();
    let store = fixture.store();
    let resolved = lazyspec::cli::context::resolve_chain(&store, "RFC-001").unwrap();

    assert_eq!(resolved.chain.len(), 1);
    assert_eq!(resolved.chain[0].title, "Auth Redesign");
    assert_eq!(resolved.target_index, 0);
}

#[test]
fn context_json_output() {
    let fixture = setup();
    let store = fixture.store();
    let output = lazyspec::cli::context::run_json(&store, "ITERATION-001").unwrap();
    let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();

    let chain = parsed["chain"].as_array().unwrap();
    assert_eq!(chain.len(), 3);
    assert_eq!(chain[0]["type"], "rfc");
    assert_eq!(chain[1]["type"], "story");
    assert_eq!(chain[2]["type"], "iteration");
    assert_eq!(chain[0]["title"], "Auth Redesign");
}

#[test]
fn context_human_output() {
    let fixture = setup();
    let store = fixture.store();
    let output = lazyspec::cli::context::run_human(&store, "ITERATION-001").unwrap();

    assert!(output.contains("Auth Redesign"));
    assert!(output.contains("Auth Implementation"));
    assert!(output.contains("Auth Sprint 1"));
    assert!(output.contains("rfc"));
    assert!(output.contains("story"));
    assert!(output.contains("iteration"));
}

#[test]
fn context_not_found() {
    let fixture = setup();
    let store = fixture.store();
    let result = lazyspec::cli::context::resolve_chain(&store, "NONEXISTENT-999");
    assert!(result.is_err());
}

fn setup_with_related() -> TestFixture {
    let fixture = TestFixture::new();
    fixture.write_doc(
        "docs/rfcs/RFC-001-auth.md",
        "---\ntitle: \"Auth Redesign\"\ntype: rfc\nstatus: accepted\nauthor: jkaloger\ndate: 2026-03-01\ntags: [security]\nrelated:\n- related to: docs/adrs/ADR-001-tokens.md\n---\n\nRFC body.\n",
    );
    fixture.write_doc(
        "docs/adrs/ADR-001-tokens.md",
        "---\ntitle: \"Token Strategy\"\ntype: adr\nstatus: accepted\nauthor: jkaloger\ndate: 2026-03-01\ntags: []\nrelated: []\n---\n\nADR body.\n",
    );
    fixture.write_doc(
        "docs/stories/STORY-001-auth-impl.md",
        "---\ntitle: \"Auth Implementation\"\ntype: story\nstatus: draft\nauthor: jkaloger\ndate: 2026-03-02\ntags: [security]\nrelated:\n- implements: docs/rfcs/RFC-001-auth.md\n---\n\nStory body.\n",
    );
    fixture.write_doc(
        "docs/iterations/ITERATION-001-auth-sprint.md",
        "---\ntitle: \"Auth Sprint 1\"\ntype: iteration\nstatus: draft\nauthor: agent\ndate: 2026-03-03\ntags: []\nrelated:\n- implements: docs/stories/STORY-001-auth-impl.md\n---\n\nIteration body.\n",
    );
    fixture
}

#[test]
fn forward_context_from_rfc() {
    let fixture = TestFixture::new();
    fixture.write_doc(
        "docs/rfcs/RFC-001-auth.md",
        "---\ntitle: \"Auth Redesign\"\ntype: rfc\nstatus: accepted\nauthor: jkaloger\ndate: 2026-03-01\ntags: []\nrelated: []\n---\n\nRFC body.\n",
    );
    fixture.write_story(
        "STORY-001-impl-a.md",
        "Impl A",
        "draft",
        Some("docs/rfcs/RFC-001-auth.md"),
    );
    fixture.write_story(
        "STORY-002-impl-b.md",
        "Impl B",
        "draft",
        Some("docs/rfcs/RFC-001-auth.md"),
    );

    let store = fixture.store();
    let resolved = lazyspec::cli::context::resolve_chain(&store, "RFC-001").unwrap();

    assert_eq!(resolved.chain.len(), 1);
    assert_eq!(resolved.forward.len(), 2);
    let forward_titles: Vec<&str> = resolved.forward.iter().map(|d| d.title.as_str()).collect();
    assert!(forward_titles.contains(&"Impl A"));
    assert!(forward_titles.contains(&"Impl B"));
}

#[test]
fn forward_context_from_story() {
    let fixture = TestFixture::new();
    fixture.write_rfc("RFC-001-auth.md", "Auth Redesign", "accepted");
    fixture.write_story(
        "STORY-001-auth.md",
        "Auth Implementation",
        "draft",
        Some("docs/rfcs/RFC-001-auth.md"),
    );
    fixture.write_iteration(
        "ITERATION-001-sprint1.md",
        "Sprint 1",
        "draft",
        Some("docs/stories/STORY-001-auth.md"),
    );
    fixture.write_iteration(
        "ITERATION-002-sprint2.md",
        "Sprint 2",
        "draft",
        Some("docs/stories/STORY-001-auth.md"),
    );

    let store = fixture.store();
    let resolved = lazyspec::cli::context::resolve_chain(&store, "STORY-001").unwrap();

    assert_eq!(resolved.chain.len(), 2);
    assert_eq!(resolved.chain[0].title, "Auth Redesign");
    assert_eq!(resolved.chain[1].title, "Auth Implementation");
    assert_eq!(resolved.forward.len(), 2);
    let forward_titles: Vec<&str> = resolved.forward.iter().map(|d| d.title.as_str()).collect();
    assert!(forward_titles.contains(&"Sprint 1"));
    assert!(forward_titles.contains(&"Sprint 2"));
}

#[test]
fn you_are_here_marker() {
    let fixture = setup();
    let store = fixture.store();
    let output = lazyspec::cli::context::run_human(&store, "STORY-001").unwrap();

    let marker = "\u{2190} you are here";
    let marker_count = output.matches(marker).count();
    assert_eq!(
        marker_count, 1,
        "expected exactly one 'you are here' marker, found {}",
        marker_count
    );

    let marker_line = output.lines().find(|l| l.contains(marker)).unwrap();
    assert!(
        marker_line.contains("Auth Implementation"),
        "marker should be on the Story line, got: {}",
        marker_line
    );
    assert!(!marker_line.contains("Auth Redesign"));
    assert!(!marker_line.contains("Auth Sprint 1"));
}

#[test]
fn related_records_in_human_output() {
    let fixture = setup_with_related();
    let store = fixture.store();
    let output = lazyspec::cli::context::run_human(&store, "STORY-001").unwrap();

    assert!(
        output.contains("related"),
        "output should contain 'related' section header"
    );
    assert!(
        output.contains("Token Strategy"),
        "output should contain the related document title"
    );
}

#[test]
fn related_records_omitted_when_none() {
    let fixture = setup();
    let store = fixture.store();
    let output = lazyspec::cli::context::run_human(&store, "STORY-001").unwrap();

    assert!(
        !output.contains("related"),
        "output should not contain 'related' when there are no related-to links"
    );
}

#[test]
fn json_related_field_present() {
    let fixture = setup_with_related();
    let store = fixture.store();
    let output = lazyspec::cli::context::run_json(&store, "STORY-001").unwrap();
    let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();

    let related = parsed["related"].as_array().unwrap();
    assert!(!related.is_empty(), "related array should be non-empty");
    let titles: Vec<&str> = related.iter().filter_map(|r| r["title"].as_str()).collect();
    assert!(
        titles.contains(&"Token Strategy"),
        "related should contain 'Token Strategy'"
    );
}

#[test]
fn json_related_empty() {
    let fixture = setup();
    let store = fixture.store();
    let output = lazyspec::cli::context::run_json(&store, "STORY-001").unwrap();
    let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();

    let related = parsed["related"].as_array().unwrap();
    assert!(
        related.is_empty(),
        "related array should be empty when no related-to links exist"
    );
}

#[test]
fn no_forward_children_for_leaf() {
    let fixture = setup();
    let store = fixture.store();
    let resolved = lazyspec::cli::context::resolve_chain(&store, "ITERATION-001").unwrap();

    assert!(
        resolved.forward.is_empty(),
        "leaf node should have no forward children"
    );
}