use crate::sdd::spec::ir::MainSpecDoc;
use anyhow::{Context as _, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ReqNode {
pub req_id: String,
pub title: String,
pub statement: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ScenarioNode {
pub req_id: String,
pub id: String,
pub given: String,
#[serde(rename = "when")]
pub when_: String,
#[serde(rename = "then")]
pub then_: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DocNode {
pub spec_id: String,
pub purpose: String,
pub reqs: Vec<ReqNode>,
#[serde(default)]
pub scenarios: Vec<ScenarioNode>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TreeIndex {
pub version: u32,
pub spec_hash: String,
pub build_timestamp: String,
pub chat_model: String,
pub docs: Vec<DocNode>,
}
const TREE_VERSION: u32 = 1;
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(),
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();
docs.sort_by(|a, b| a.spec_id.cmp(&b.spec_id));
docs
}
impl TreeIndex {
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,
}
}
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(())
}
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);
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();
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");
}
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() {
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");
assert_eq!(docs[0].scenarios[0].given, "some given");
}
#[test]
fn test_build_docs_drops_feature_false() {
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() {
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");
}
}