Skip to main content

edda_pack/
lib.rs

1use edda_index::{fetch_store_line, read_index_tail, IndexRecordV1};
2use edda_ledger::view::DecisionView;
3use serde::{Deserialize, Serialize};
4use std::collections::{BTreeMap, HashMap, HashSet};
5use std::path::Path;
6
7const DEFAULT_INDEX_TAIL_LINES: usize = 5000;
8const DEFAULT_INDEX_TAIL_MAX_BYTES: u64 = 8 * 1024 * 1024; // 8MB
9const DEFAULT_PACK_TURNS: usize = 12;
10const DEFAULT_PACK_BUDGET_CHARS: usize = 12000;
11
12// ── Turn + ToolUse structs ──
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct Turn {
16    pub user_uuid: String,
17    pub assistant_uuid: String,
18    pub user_text: String,
19    pub assistant_texts: Vec<String>,
20    pub tool_uses: Vec<ToolUse>,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct ToolUse {
25    pub id: Option<String>,
26    pub name: String,
27    pub command: Option<String>,
28    pub description: Option<String>,
29    pub file_path: Option<String>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct PackMetadata {
34    pub project_id: String,
35    pub session_id: String,
36    pub git_branch: String,
37    pub turn_count: usize,
38    pub budget_chars: usize,
39}
40
41// ── Turn alignment via uuid/parentUuid ──
42
43/// Build turns from index records by matching assistant.parentUuid → user.uuid.
44pub fn build_turns(
45    project_dir: &Path,
46    session_id: &str,
47    max_turns: usize,
48) -> anyhow::Result<Vec<Turn>> {
49    let tail_lines: usize = std::env::var("EDDA_INDEX_TAIL_LINES")
50        .ok()
51        .and_then(|v| v.parse().ok())
52        .unwrap_or(DEFAULT_INDEX_TAIL_LINES);
53    let tail_bytes: u64 = std::env::var("EDDA_INDEX_TAIL_MAX_BYTES")
54        .ok()
55        .and_then(|v| v.parse().ok())
56        .unwrap_or(DEFAULT_INDEX_TAIL_MAX_BYTES);
57    let pack_turns: usize = std::env::var("EDDA_PACK_TURNS")
58        .ok()
59        .and_then(|v| v.parse().ok())
60        .unwrap_or(DEFAULT_PACK_TURNS);
61    let max_turns = max_turns.min(pack_turns);
62
63    let index_path = project_dir
64        .join("index")
65        .join(format!("{session_id}.jsonl"));
66    let records = read_index_tail(&index_path, tail_lines, tail_bytes)?;
67
68    if records.is_empty() {
69        return Ok(vec![]);
70    }
71
72    // Build lookup by uuid
73    let by_uuid: HashMap<String, &IndexRecordV1> =
74        records.iter().map(|r| (r.uuid.clone(), r)).collect();
75
76    // Collect assistant records in order
77    let assistants: Vec<&IndexRecordV1> = records
78        .iter()
79        .filter(|r| r.record_type == "assistant")
80        .collect();
81
82    let store_path = project_dir
83        .join("transcripts")
84        .join(format!("{session_id}.jsonl"));
85
86    let mut turns = Vec::new();
87    let mut seen_user_uuids = HashSet::new();
88
89    // Process newest assistant first
90    for asst_rec in assistants.iter().rev() {
91        if turns.len() >= max_turns {
92            break;
93        }
94
95        // Walk UP the parentUuid chain to find the real user prompt.
96        // Claude Code transcript structure:
97        //   user(STRING) → assistant(tool_use) → user(tool_result) → assistant(tool_use) → ... → assistant(text)
98        // We start from the leaf assistant and walk up to find the root user with STRING content.
99        let mut current_parent = asst_rec.parent_uuid.as_deref();
100        let mut chain_tool_uses: Vec<ToolUse> = Vec::new();
101        let mut real_user_uuid = String::new();
102        let mut real_user_text = String::new();
103
104        let mut depth = 0;
105        const MAX_CHAIN_DEPTH: usize = 50;
106
107        while let Some(parent_id) = current_parent {
108            if depth >= MAX_CHAIN_DEPTH {
109                break;
110            }
111            depth += 1;
112
113            let parent_rec = match by_uuid.get(parent_id) {
114                Some(r) => r,
115                None => break,
116            };
117
118            if parent_rec.record_type == "user" {
119                // Try to extract user text from this record
120                if let Ok(raw) =
121                    fetch_store_line(&store_path, parent_rec.store_offset, parent_rec.store_len)
122                {
123                    if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&raw) {
124                        let text = extract_user_text(&json);
125                        if !text.is_empty() {
126                            real_user_uuid = parent_rec.uuid.clone();
127                            real_user_text = text;
128                            break; // Found the real user prompt
129                        }
130                    }
131                }
132                // Content is array (tool_result) or empty → keep walking up
133                current_parent = parent_rec.parent_uuid.as_deref();
134            } else if parent_rec.record_type == "assistant" {
135                // Intermediate assistant → collect its tool_uses
136                if let Ok(raw) =
137                    fetch_store_line(&store_path, parent_rec.store_offset, parent_rec.store_len)
138                {
139                    if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&raw) {
140                        let (_, tus) = parse_assistant_content(&json);
141                        chain_tool_uses.extend(tus);
142                    }
143                }
144                current_parent = parent_rec.parent_uuid.as_deref();
145            } else {
146                break; // unexpected record type
147            }
148        }
149
150        if real_user_text.is_empty() || real_user_uuid.is_empty() {
151            continue;
152        }
153
154        // Dedup: only one turn per real user prompt
155        if !seen_user_uuids.insert(real_user_uuid.clone()) {
156            continue;
157        }
158
159        // Parse final (leaf) assistant content
160        let asst_raw =
161            match fetch_store_line(&store_path, asst_rec.store_offset, asst_rec.store_len) {
162                Ok(r) => r,
163                Err(_) => continue,
164            };
165        let asst_json: serde_json::Value = match serde_json::from_slice(&asst_raw) {
166            Ok(v) => v,
167            Err(_) => continue,
168        };
169        let (assistant_texts, final_tool_uses) = parse_assistant_content(&asst_json);
170
171        // Merge tool_uses: chain (reversed to chronological) + final assistant's
172        chain_tool_uses.reverse();
173        chain_tool_uses.extend(final_tool_uses);
174
175        turns.push(Turn {
176            user_uuid: real_user_uuid,
177            assistant_uuid: asst_rec.uuid.clone(),
178            user_text: real_user_text,
179            assistant_texts,
180            tool_uses: chain_tool_uses,
181        });
182    }
183
184    Ok(turns)
185}
186
187/// Extract user text from a transcript user record.
188/// Returns non-empty string only for real user prompts (STRING content).
189/// Returns empty for tool_result arrays (these are tool execution results, not user input).
190fn extract_user_text(user_json: &serde_json::Value) -> String {
191    let content = match user_json.get("message").and_then(|m| m.get("content")) {
192        Some(c) => c,
193        None => return String::new(),
194    };
195
196    // String content → real user prompt
197    if let Some(s) = content.as_str() {
198        return s.to_string();
199    }
200
201    // Array content → check block types
202    if let Some(arr) = content.as_array() {
203        // If any block is tool_result, this is NOT a real user prompt
204        let has_tool_result = arr
205            .iter()
206            .any(|b| b.get("type").and_then(|t| t.as_str()) == Some("tool_result"));
207        if has_tool_result {
208            return String::new();
209        }
210
211        // Extract text from text blocks (handles ARRAY(text) format)
212        let texts: Vec<&str> = arr
213            .iter()
214            .filter_map(|b| {
215                if b.get("type").and_then(|t| t.as_str()) == Some("text") {
216                    b.get("text").and_then(|t| t.as_str())
217                } else {
218                    None
219                }
220            })
221            .collect();
222        if !texts.is_empty() {
223            return texts.join(" ");
224        }
225    }
226
227    String::new()
228}
229
230fn parse_assistant_content(asst_json: &serde_json::Value) -> (Vec<String>, Vec<ToolUse>) {
231    let mut texts = Vec::new();
232    let mut tool_uses = Vec::new();
233
234    let content = asst_json.get("message").and_then(|m| m.get("content"));
235
236    if let Some(arr) = content.and_then(|c| c.as_array()) {
237        for block in arr {
238            let block_type = block.get("type").and_then(|v| v.as_str()).unwrap_or("");
239            match block_type {
240                "text" => {
241                    if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
242                        texts.push(text.to_string());
243                    }
244                }
245                "tool_use" => {
246                    let name = block
247                        .get("name")
248                        .and_then(|v| v.as_str())
249                        .unwrap_or("")
250                        .to_string();
251                    let id = block.get("id").and_then(|v| v.as_str()).map(|s| s.into());
252                    let input = block.get("input");
253                    let command = input
254                        .and_then(|i| i.get("command"))
255                        .and_then(|c| c.as_str())
256                        .map(|s| s.into());
257                    let description = input
258                        .and_then(|i| i.get("description"))
259                        .and_then(|d| d.as_str())
260                        .map(|s| s.into());
261                    let file_path = input
262                        .and_then(|i| i.get("file_path"))
263                        .and_then(|f| f.as_str())
264                        .map(|s| s.into());
265
266                    tool_uses.push(ToolUse {
267                        id,
268                        name,
269                        command,
270                        description,
271                        file_path,
272                    });
273                }
274                _ => {}
275            }
276        }
277    } else if let Some(text) = content.and_then(|c| c.as_str()) {
278        texts.push(text.to_string());
279    }
280
281    (texts, tool_uses)
282}
283
284// ── Pack rendering ──
285
286/// Render turns into a markdown pack string with budget truncation.
287pub fn render_pack(turns: &[Turn], metadata: &PackMetadata, budget_chars: usize) -> String {
288    let budget = if budget_chars == 0 {
289        std::env::var("EDDA_PACK_BUDGET_CHARS")
290            .ok()
291            .and_then(|v| v.parse().ok())
292            .unwrap_or(DEFAULT_PACK_BUDGET_CHARS)
293    } else {
294        budget_chars
295    };
296
297    let mut out = String::new();
298    out.push_str("# edda memory pack (hot)\n\n");
299    out.push_str(&format!("- project_id: {}\n", metadata.project_id));
300    out.push_str(&format!("- session_id: {}\n", metadata.session_id));
301    out.push_str(&format!("- git_branch: {}\n", metadata.git_branch));
302    out.push_str(&format!("- turns: {}\n\n", turns.len()));
303    out.push_str("## Recent Turns (deterministic)\n\n");
304
305    // Render turns, newest first, truncate from oldest if over budget
306    for (i, turn) in turns.iter().enumerate() {
307        let mut section = String::new();
308        let user_preview = truncate_str(&turn.user_text, 200);
309        section.push_str(&format!("### Turn {} (newest first)\n", i + 1));
310        section.push_str(&format!("- User: {user_preview}\n"));
311
312        for tu in &turn.tool_uses {
313            let cmd_str = tu
314                .command
315                .as_deref()
316                .map(|c| format!(" `{}`", truncate_str(c, 80)))
317                .unwrap_or_default();
318            let desc_str = tu
319                .description
320                .as_deref()
321                .map(|d| format!(" ({})", truncate_str(d, 60)))
322                .unwrap_or_default();
323            let file_str = tu
324                .file_path
325                .as_deref()
326                .map(|f| format!(" file={f}"))
327                .unwrap_or_default();
328            section.push_str(&format!(
329                "  - ToolUse: {}{}{}{}\n",
330                tu.name, cmd_str, desc_str, file_str
331            ));
332        }
333
334        for text in &turn.assistant_texts {
335            let preview = truncate_str(text, 300);
336            section.push_str(&format!("  - Assistant: {preview}\n"));
337        }
338        section.push('\n');
339
340        if out.len() + section.len() > budget {
341            out.push_str(&format!(
342                "... ({} more turns truncated by budget)\n",
343                turns.len() - i
344            ));
345            break;
346        }
347        out.push_str(&section);
348    }
349
350    out
351}
352
353fn truncate_str(s: &str, max: usize) -> String {
354    if s.len() <= max {
355        s.replace('\n', " ")
356    } else {
357        // Find the last char boundary at or before `max` bytes
358        let mut end = max;
359        while end > 0 && !s.is_char_boundary(end) {
360            end -= 1;
361        }
362        format!("{}...", s[..end].replace('\n', " "))
363    }
364}
365
366/// Write hot.md and hot.meta.json to the packs directory.
367pub fn write_pack(project_dir: &Path, pack_md: &str, meta: &PackMetadata) -> anyhow::Result<()> {
368    let packs_dir = project_dir.join("packs");
369    std::fs::create_dir_all(&packs_dir)?;
370
371    edda_store::write_atomic(&packs_dir.join("hot.md"), pack_md.as_bytes())?;
372
373    let meta_json = serde_json::to_string_pretty(meta)?;
374    edda_store::write_atomic(&packs_dir.join("hot.meta.json"), meta_json.as_bytes())?;
375
376    Ok(())
377}
378
379// ── Doctrine Pack (judgment layer) ──
380
381const DEFAULT_DOCTRINE_FILE: &str = ".havamal-pack.md";
382
383/// Read the project's doctrine hot pack (judgment layer).
384///
385/// Contract with havamal (github.com/fagemx/havamal): the project curates
386/// judgment — ideology, failure memory, taste — as doctrine files and
387/// generates a compressed pack via `havamal pack --out .havamal-pack.md`.
388/// edda transports that pack into the session. edda never generates judgment
389/// itself: machine-extracted judgment without curation is noise; facts flow
390/// automatically, judgment enters signed.
391///
392/// Resolution order:
393/// 1. `EDDA_DOCTRINE_PATH` env var (absolute, or relative to `repo_root`)
394/// 2. `<repo_root>/.havamal-pack.md`
395///
396/// Returns `None` when no doctrine source exists or the file is empty.
397/// Content is truncated at `budget` bytes on a char boundary.
398pub fn read_doctrine_pack(repo_root: &Path, budget: usize) -> Option<String> {
399    let path = match std::env::var("EDDA_DOCTRINE_PATH") {
400        Ok(p) if !p.trim().is_empty() => {
401            let pb = std::path::PathBuf::from(p.trim());
402            if pb.is_absolute() {
403                pb
404            } else {
405                repo_root.join(pb)
406            }
407        }
408        _ => repo_root.join(DEFAULT_DOCTRINE_FILE),
409    };
410
411    let raw = std::fs::read_to_string(&path).ok()?;
412    let trimmed = raw.trim();
413    if trimmed.is_empty() {
414        return None;
415    }
416
417    let mut body = trimmed.to_string();
418    if body.len() > budget {
419        let mut end = budget;
420        while end > 0 && !body.is_char_boundary(end) {
421            end -= 1;
422        }
423        body.truncate(end);
424        body.push_str("\n<!-- doctrine truncated by EDDA_DOCTRINE_BUDGET_CHARS -->");
425    }
426
427    Some(format!("## Doctrine (judgment layer)\n\n{body}"))
428}
429
430// ── Decision Pack ──
431
432/// A pack of active decisions grouped by domain, ready for session injection.
433#[derive(Debug, Clone)]
434pub struct DecisionPack {
435    /// Decisions grouped by domain (e.g., "db", "error", "auth")
436    pub groups: Vec<DecisionGroup>,
437    /// Total number of decisions included
438    pub total: usize,
439    /// Branch these decisions are scoped to
440    pub branch: String,
441}
442
443/// A group of decisions sharing the same domain prefix.
444#[derive(Debug, Clone)]
445pub struct DecisionGroup {
446    /// Domain name (e.g., "db", "error", "auth")
447    pub domain: String,
448    /// Decisions in this domain, sorted by key
449    pub decisions: Vec<DecisionSummary>,
450}
451
452/// Minimal decision summary for pack rendering (avoids carrying full DecisionView).
453#[derive(Debug, Clone)]
454pub struct DecisionSummary {
455    pub key: String,
456    pub value: String,
457    pub reason: String,
458    pub status: String,
459    pub authority: String,
460    pub reversibility: String,
461    pub affected_paths: Vec<String>,
462}
463
464impl From<&DecisionView> for DecisionSummary {
465    fn from(v: &DecisionView) -> Self {
466        Self {
467            key: v.key.clone(),
468            value: v.value.clone(),
469            reason: v.reason.clone(),
470            status: v.status.clone(),
471            authority: v.authority.clone(),
472            reversibility: v.reversibility.clone(),
473            affected_paths: v.affected_paths.clone(),
474        }
475    }
476}
477
478/// Build a decision pack from active decisions in the ledger.
479///
480/// Queries active decisions (status IN active, experimental) on the given
481/// branch, groups by domain, and limits to `max_items` total decisions.
482///
483/// Returns a pack with 0 groups if no active decisions exist.
484pub fn build_decision_pack(repo_root: &Path, branch: &str, max_items: usize) -> DecisionPack {
485    let views: Vec<DecisionView> = match edda_ledger::Ledger::open(repo_root) {
486        Ok(ledger) => ledger
487            .active_decisions_limited(None, None, None, None, max_items)
488            .unwrap_or_default(),
489        Err(_) => Vec::new(),
490    };
491
492    if views.is_empty() {
493        return DecisionPack {
494            groups: Vec::new(),
495            total: 0,
496            branch: branch.to_string(),
497        };
498    }
499
500    // Group by domain, limit to max_items total
501    let mut by_domain: BTreeMap<String, Vec<DecisionSummary>> = BTreeMap::new();
502    let mut count = 0;
503
504    for d in &views {
505        if count >= max_items {
506            break;
507        }
508        by_domain
509            .entry(d.domain.clone())
510            .or_default()
511            .push(DecisionSummary::from(d));
512        count += 1;
513    }
514
515    let groups = by_domain
516        .into_iter()
517        .map(|(domain, mut decisions)| {
518            decisions.sort_by(|a, b| a.key.cmp(&b.key));
519            DecisionGroup { domain, decisions }
520        })
521        .collect();
522
523    DecisionPack {
524        groups,
525        total: count,
526        branch: branch.to_string(),
527    }
528}
529
530/// Render a decision pack as a markdown section.
531///
532/// Returns an empty string if the pack has 0 decisions.
533pub fn render_decision_pack_md(pack: &DecisionPack) -> String {
534    if pack.total == 0 {
535        return String::new();
536    }
537
538    let mut lines = vec![format!(
539        "## Active Decisions ({} on `{}`)",
540        pack.total, pack.branch
541    )];
542
543    for group in &pack.groups {
544        lines.push(format!("\n### {}", group.domain));
545        for d in &group.decisions {
546            let mut entry = format!("- **`{}={}`**", d.key, d.value);
547            if !d.reason.is_empty() {
548                entry.push_str(&format!(" — {}", d.reason));
549            }
550            if !d.affected_paths.is_empty() {
551                entry.push_str(&format!("\n  paths: `{}`", d.affected_paths.join("`, `")));
552            }
553            lines.push(entry);
554        }
555    }
556
557    lines.join("\n")
558}
559
560#[cfg(test)]
561mod tests {
562    use super::*;
563
564    #[test]
565    fn render_pack_basic() {
566        let turns = vec![Turn {
567            user_uuid: "u1".into(),
568            assistant_uuid: "a1".into(),
569            user_text: "How do I sort a list?".into(),
570            assistant_texts: vec!["Use the sort() method.".into()],
571            tool_uses: vec![ToolUse {
572                id: Some("tu1".into()),
573                name: "Bash".into(),
574                command: Some("ls -la".into()),
575                description: Some("List files".into()),
576                file_path: None,
577            }],
578        }];
579
580        let meta = PackMetadata {
581            project_id: "abc123".into(),
582            session_id: "s1".into(),
583            git_branch: "main".into(),
584            turn_count: 1,
585            budget_chars: 12000,
586        };
587
588        let md = render_pack(&turns, &meta, 12000);
589        assert!(md.contains("# edda memory pack (hot)"));
590        assert!(md.contains("How do I sort a list?"));
591        assert!(md.contains("ToolUse: Bash"));
592        assert!(md.contains("Use the sort() method."));
593    }
594
595    #[test]
596    fn render_pack_budget_truncation() {
597        let turns: Vec<Turn> = (0..20)
598            .map(|i| Turn {
599                user_uuid: format!("u{i}"),
600                assistant_uuid: format!("a{i}"),
601                user_text: format!("Question {i} with some extra text padding to fill space"),
602                assistant_texts: vec![format!(
603                    "Answer {i} with a reasonably long response text to consume budget"
604                )],
605                tool_uses: vec![],
606            })
607            .collect();
608
609        let meta = PackMetadata {
610            project_id: "abc".into(),
611            session_id: "s1".into(),
612            git_branch: "main".into(),
613            turn_count: 20,
614            budget_chars: 500,
615        };
616
617        let md = render_pack(&turns, &meta, 500);
618        assert!(md.contains("truncated by budget"));
619        assert!(md.len() <= 600); // some slack for the truncation message
620    }
621
622    #[test]
623    fn write_pack_creates_files() {
624        let tmp = tempfile::tempdir().unwrap();
625        let meta = PackMetadata {
626            project_id: "test".into(),
627            session_id: "s1".into(),
628            git_branch: "main".into(),
629            turn_count: 0,
630            budget_chars: 12000,
631        };
632
633        write_pack(tmp.path(), "# pack content", &meta).unwrap();
634
635        let hot = tmp.path().join("packs").join("hot.md");
636        assert!(hot.exists());
637        assert_eq!(std::fs::read_to_string(&hot).unwrap(), "# pack content");
638
639        let meta_path = tmp.path().join("packs").join("hot.meta.json");
640        assert!(meta_path.exists());
641    }
642
643    // ── Doctrine Pack tests ──
644
645    #[test]
646    fn doctrine_pack_missing_returns_none() {
647        let tmp = tempfile::tempdir().unwrap();
648        assert!(read_doctrine_pack(tmp.path(), 4000).is_none());
649    }
650
651    #[test]
652    fn doctrine_pack_reads_default_file() {
653        let tmp = tempfile::tempdir().unwrap();
654        std::fs::write(
655            tmp.path().join(".havamal-pack.md"),
656            "## L1 — MUST STAY TRUE\nClaims never close work.",
657        )
658        .unwrap();
659        let md = read_doctrine_pack(tmp.path(), 4000).unwrap();
660        assert!(md.starts_with("## Doctrine (judgment layer)"));
661        assert!(md.contains("Claims never close work."));
662    }
663
664    #[test]
665    fn doctrine_pack_empty_file_returns_none() {
666        let tmp = tempfile::tempdir().unwrap();
667        std::fs::write(tmp.path().join(".havamal-pack.md"), "  \n\n").unwrap();
668        assert!(read_doctrine_pack(tmp.path(), 4000).is_none());
669    }
670
671    #[test]
672    fn doctrine_pack_truncates_at_budget() {
673        let tmp = tempfile::tempdir().unwrap();
674        let long = "x".repeat(500);
675        std::fs::write(tmp.path().join(".havamal-pack.md"), &long).unwrap();
676        let md = read_doctrine_pack(tmp.path(), 100).unwrap();
677        assert!(md.contains("doctrine truncated"));
678        assert!(md.len() < 300);
679    }
680
681    // ── Decision Pack tests ──
682
683    fn make_summary(key: &str, value: &str, reason: &str, paths: Vec<&str>) -> DecisionSummary {
684        DecisionSummary {
685            key: key.to_string(),
686            value: value.to_string(),
687            reason: reason.to_string(),
688            status: "active".to_string(),
689            authority: "human".to_string(),
690            reversibility: "medium".to_string(),
691            affected_paths: paths.into_iter().map(|s| s.to_string()).collect(),
692        }
693    }
694
695    fn make_pack(groups: Vec<(&str, Vec<DecisionSummary>)>, branch: &str) -> DecisionPack {
696        let total: usize = groups.iter().map(|(_, ds)| ds.len()).sum();
697        DecisionPack {
698            groups: groups
699                .into_iter()
700                .map(|(domain, decisions)| DecisionGroup {
701                    domain: domain.to_string(),
702                    decisions,
703                })
704                .collect(),
705            total,
706            branch: branch.to_string(),
707        }
708    }
709
710    #[test]
711    fn test_empty_pack() {
712        let pack = DecisionPack {
713            groups: Vec::new(),
714            total: 0,
715            branch: "main".to_string(),
716        };
717        let md = render_decision_pack_md(&pack);
718        assert!(md.is_empty());
719    }
720
721    #[test]
722    fn test_full_pack_grouped_by_domain() {
723        let pack = make_pack(
724            vec![
725                (
726                    "auth",
727                    vec![make_summary("auth.strategy", "JWT", "stateless", vec![])],
728                ),
729                (
730                    "db",
731                    vec![
732                        make_summary("db.engine", "sqlite", "embedded", vec![]),
733                        make_summary("db.pool", "r2d2", "connection pooling", vec![]),
734                    ],
735                ),
736                (
737                    "error",
738                    vec![
739                        make_summary("error.lib", "thiserror", "typed errors", vec![]),
740                        make_summary("error.pattern", "enum", "exhaustive", vec![]),
741                    ],
742                ),
743            ],
744            "main",
745        );
746
747        assert_eq!(pack.groups.len(), 3);
748        assert_eq!(pack.total, 5);
749
750        let md = render_decision_pack_md(&pack);
751        assert!(md.contains("## Active Decisions (5 on `main`)"));
752        assert!(md.contains("### auth"));
753        assert!(md.contains("### db"));
754        assert!(md.contains("### error"));
755    }
756
757    #[test]
758    fn test_domain_grouping_order() {
759        let pack = make_pack(
760            vec![
761                ("a_first", vec![make_summary("a_first.x", "1", "r", vec![])]),
762                (
763                    "m_middle",
764                    vec![make_summary("m_middle.x", "2", "r", vec![])],
765                ),
766                ("z_test", vec![make_summary("z_test.x", "3", "r", vec![])]),
767            ],
768            "main",
769        );
770
771        let md = render_decision_pack_md(&pack);
772        let a_pos = md.find("### a_first").unwrap();
773        let m_pos = md.find("### m_middle").unwrap();
774        let z_pos = md.find("### z_test").unwrap();
775        assert!(a_pos < m_pos);
776        assert!(m_pos < z_pos);
777    }
778
779    #[test]
780    fn test_render_with_paths() {
781        let pack = make_pack(
782            vec![(
783                "db",
784                vec![make_summary(
785                    "db.engine",
786                    "sqlite",
787                    "embedded",
788                    vec!["crates/foo/**", "src/**"],
789                )],
790            )],
791            "main",
792        );
793
794        let md = render_decision_pack_md(&pack);
795        assert!(md.contains("paths: `crates/foo/**`, `src/**`"));
796    }
797
798    #[test]
799    fn test_render_without_reason() {
800        let pack = make_pack(
801            vec![("db", vec![make_summary("db.engine", "sqlite", "", vec![])])],
802            "main",
803        );
804
805        let md = render_decision_pack_md(&pack);
806        assert!(md.contains("**`db.engine=sqlite`**"));
807        assert!(!md.contains(" — "));
808    }
809
810    #[test]
811    fn test_build_decision_pack_nonexistent_repo() {
812        // Non-existent path should return empty pack
813        let pack = build_decision_pack(Path::new("/nonexistent/path"), "main", 7);
814        assert_eq!(pack.total, 0);
815        assert!(pack.groups.is_empty());
816        assert_eq!(render_decision_pack_md(&pack), "");
817    }
818}