llman 0.0.68

A tool for managing LLM application rules(prompts) ...
Documentation
//! PageIndex tree index: maps the already-structured sdd spec IR into a
//! hierarchical tree that an agentic chat model can navigate via tool calls.
//!
//! Unlike the original PageIndex (which spends most of its effort having an LLM
//! extract a TOC from PDFs), sdd specs are *already* a tree
//! (`spec → requirement`), so building the index is a pure, LLM-free transform.

use crate::sdd::spec::ir::MainSpecDoc;
use anyhow::{Context as _, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;

/// One requirement node (leaf of the tree).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ReqNode {
    pub req_id: String,
    pub title: String,
    /// Full requirement text (contains MUST/SHALL).
    pub statement: String,
}

/// One scenario node (behavior detail under a requirement). Index rebuild may
/// embed harness scenarios derived from `.feature` files (Partitioned SSOT);
/// constraint-layer toon scenarios with `feature: false` stay documentation-only.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ScenarioNode {
    pub req_id: String,
    pub id: String,
    pub given: String,
    /// Serialized as `when` (matches `spec.toon` column / IR field rename).
    #[serde(rename = "when")]
    pub when_: String,
    /// Serialized as `then` (matches `spec.toon` column / IR field rename).
    #[serde(rename = "then")]
    pub then_: String,
}

/// One spec document node (root of a subtree).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DocNode {
    pub spec_id: String,
    /// Spec overview / purpose.
    pub purpose: String,
    pub reqs: Vec<ReqNode>,
    /// `feature: true` scenarios kept from the parsed IR so the retrieval agent
    /// can read behavior details. `#[serde(default)]` keeps old `tree.json`
    /// (built before this field existed) loadable — they deserialize with an
    /// empty list and gain scenarios on the next rebuild (progressive migration).
    #[serde(default)]
    pub scenarios: Vec<ScenarioNode>,
}

/// Serialized pageindex tree index.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TreeIndex {
    pub version: u32,
    /// Hash of the source specs for freshness checks.
    pub spec_hash: String,
    pub build_timestamp: String,
    /// Chat model recorded at build time (informational only; building is LLM-free).
    pub chat_model: String,
    pub docs: Vec<DocNode>,
}

const TREE_VERSION: u32 = 1;

/// Build the tree documents from parsed spec IR.
///
/// Takes `(spec_id, MainSpecDoc)` pairs (spec_id is the spec directory name, kept
/// consistent with the old spec_id scheme so retrieval IDs are comparable
/// across backends). The parser-level `Spec` type is intentionally avoided: it
/// drops `req_id`/`title` during conversion, which the tree needs.
pub fn build_docs(parsed: &[(String, MainSpecDoc)]) -> Vec<DocNode> {
    let mut docs: Vec<DocNode> = parsed
        .iter()
        .map(|(spec_id, doc)| DocNode {
            spec_id: spec_id.clone(),
            purpose: doc.purpose.clone(),
            reqs: doc
                .requirements
                .iter()
                .map(|r| ReqNode {
                    req_id: r.req_id.clone(),
                    title: r.title.clone(),
                    statement: r.statement.clone(),
                })
                .collect(),
            // Keep only `feature: true` scenarios from toon as a legacy fallback;
            // primary harness embed comes from `.feature` files when BDD-on.
            // `feature: false` scenarios stay in `spec.toon` as documentation
            // only and are intentionally excluded from the retrieval index.
            scenarios: doc
                .scenarios
                .iter()
                .filter(|s| s.feature)
                .map(|s| ScenarioNode {
                    req_id: s.req_id.clone(),
                    id: s.id.clone(),
                    given: s.given.clone(),
                    when_: s.when_.clone(),
                    then_: s.then_.clone(),
                })
                .collect(),
        })
        .collect();
    // Deterministic ordering independent of directory iteration order.
    docs.sort_by(|a, b| a.spec_id.cmp(&b.spec_id));
    docs
}

impl TreeIndex {
    /// Assemble a new tree index from built docs plus build metadata.
    pub fn new(
        docs: Vec<DocNode>,
        spec_hash: String,
        build_timestamp: String,
        chat_model: String,
    ) -> Self {
        Self {
            version: TREE_VERSION,
            spec_hash,
            build_timestamp,
            chat_model,
            docs,
        }
    }

    /// Serialize to `<dir>/tree.json`.
    pub fn save(&self, dir: &Path) -> Result<()> {
        std::fs::create_dir_all(dir)
            .with_context(|| format!("Failed to create pageindex dir {}", dir.display()))?;
        let json = serde_json::to_string_pretty(self).context("Failed to serialize tree.json")?;
        std::fs::write(dir.join("tree.json"), json)
            .with_context(|| format!("Failed to write {}/tree.json", dir.display()))?;
        Ok(())
    }

    /// Deserialize from `<dir>/tree.json`.
    pub fn load(dir: &Path) -> Result<Self> {
        let path = dir.join("tree.json");
        let content = std::fs::read_to_string(&path)
            .with_context(|| format!("Failed to read {}", path.display()))?;
        let idx: TreeIndex = serde_json::from_str(&content).context("Failed to parse tree.json")?;
        Ok(idx)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::sdd::spec::ir::{MainSpecDoc, RequirementEntry, ScenarioEntry};

    fn sample_doc(name: &str, purpose: &str, reqs: &[(&str, &str, &str)]) -> (String, MainSpecDoc) {
        let requirements = reqs
            .iter()
            .map(|(rid, title, stmt)| RequirementEntry {
                req_id: rid.to_string(),
                title: title.to_string(),
                statement: stmt.to_string(),
            })
            .collect();
        (
            name.to_string(),
            MainSpecDoc {
                kind: "llman.sdd.spec".to_string(),
                name: name.to_string(),
                purpose: purpose.to_string(),
                valid_scope: Vec::new(),
                requirements,
                scenarios: Vec::new(),
            },
        )
    }

    #[test]
    fn test_build_docs_preserves_structure_and_ids() {
        let parsed = vec![
            sample_doc(
                "sdd-workflow",
                "Define the SDD workflow.",
                &[
                    ("r1", "Init scaffold", "`llman sdd init` MUST create dirs."),
                    ("r2", "Update", "`llman sdd update` MUST refresh AGENTS.md."),
                ],
            ),
            sample_doc(
                "cli",
                "CLI surface.",
                &[("r1", "Commands", "MUST expose subcommands.")],
            ),
        ];

        let docs = build_docs(&parsed);

        // Deterministic ordering by spec_id.
        assert_eq!(docs.len(), 2);
        assert_eq!(docs[0].spec_id, "cli");
        assert_eq!(docs[1].spec_id, "sdd-workflow");

        let wf = &docs[1];
        assert_eq!(wf.purpose, "Define the SDD workflow.");
        assert_eq!(wf.reqs.len(), 2);
        assert_eq!(wf.reqs[0].req_id, "r1");
        assert_eq!(wf.reqs[0].title, "Init scaffold");
        assert!(wf.reqs[0].statement.contains("MUST"));
    }

    #[test]
    fn test_tree_index_save_load_roundtrip() {
        let tmp = tempfile::TempDir::new().unwrap();
        let parsed = vec![sample_doc(
            "demo",
            "demo purpose",
            &[("r1", "title", "stmt MUST x")],
        )];
        let docs = build_docs(&parsed);
        let tree = TreeIndex::new(
            docs,
            "deadbeef".to_string(),
            "2026-06-28T00:00:00Z".to_string(),
            "chat-model-x".to_string(),
        );
        tree.save(tmp.path()).unwrap();

        // tree.json was created where freshness expects it.
        assert!(tmp.path().join("tree.json").exists());

        let loaded = TreeIndex::load(tmp.path()).unwrap();
        assert_eq!(loaded.version, 1);
        assert_eq!(loaded.spec_hash, "deadbeef");
        assert_eq!(loaded.chat_model, "chat-model-x");
        assert_eq!(loaded.docs.len(), 1);
        assert_eq!(loaded.docs[0].spec_id, "demo");
        assert_eq!(loaded.docs[0].reqs[0].req_id, "r1");
    }

    /// Build a ScenarioEntry with the given req_id/id and `feature` flag.
    fn scenario(req_id: &str, id: &str, feature: bool) -> ScenarioEntry {
        ScenarioEntry {
            req_id: req_id.to_string(),
            id: id.to_string(),
            given: "some given".to_string(),
            when_: "some when".to_string(),
            then_: "some then".to_string(),
            feature,
        }
    }

    #[test]
    fn test_build_docs_preserves_scenarios() {
        // A doc with feature:true scenarios → those scenarios must survive into
        // the built DocNode.
        let doc = MainSpecDoc {
            kind: "llman.sdd.spec".into(),
            name: "demo".into(),
            purpose: "demo purpose".into(),
            valid_scope: vec![],
            requirements: vec![RequirementEntry {
                req_id: "r1".into(),
                title: "T".into(),
                statement: "MUST x".into(),
            }],
            scenarios: vec![scenario("r1", "happy", true), scenario("r1", "sad", true)],
        };
        let docs = build_docs(&[("demo".to_string(), doc)]);
        assert_eq!(docs[0].scenarios.len(), 2);
        assert_eq!(docs[0].scenarios[0].id, "happy");
        assert_eq!(docs[0].scenarios[1].id, "sad");
        // Fields are mapped through (when_/then_ carry the behavior text).
        assert_eq!(docs[0].scenarios[0].given, "some given");
    }

    #[test]
    fn test_build_docs_drops_feature_false() {
        // feature:false scenarios are documentation-only and MUST be excluded
        // from the index; feature:true ones are kept.
        let doc = MainSpecDoc {
            kind: "llman.sdd.spec".into(),
            name: "demo".into(),
            purpose: "demo purpose".into(),
            valid_scope: vec![],
            requirements: vec![RequirementEntry {
                req_id: "r1".into(),
                title: "T".into(),
                statement: "MUST x".into(),
            }],
            scenarios: vec![
                scenario("r1", "executable", true),
                scenario("r1", "doc-only", false),
            ],
        };
        let docs = build_docs(&[("demo".to_string(), doc)]);
        assert_eq!(docs[0].scenarios.len(), 1);
        assert_eq!(docs[0].scenarios[0].id, "executable");
    }

    #[test]
    fn test_docnode_loads_without_scenarios_field() {
        // Backward compatibility: an old tree.json whose DocNode objects predate
        // the `scenarios` field must still deserialize (field defaults to empty).
        let old_json = r#"{
            "spec_id": "demo",
            "purpose": "old index",
            "reqs": [{"req_id": "r1", "title": "T", "statement": "MUST x"}]
        }"#;
        let node: DocNode = serde_json::from_str(old_json).unwrap();
        assert_eq!(node.spec_id, "demo");
        assert_eq!(node.reqs.len(), 1);
        assert!(node.scenarios.is_empty(), "missing field defaults to empty");
    }
}