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 DocNode {
pub spec_id: String,
pub purpose: String,
pub reqs: Vec<ReqNode>,
}
#[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(),
})
.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};
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(),
feature_refs: None,
},
)
}
#[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");
}
}